Skip to content

py/gc: Word-align the packed allocation-table scans. - #19476

Draft
Gadgetoid wants to merge 3 commits into
micropython:masterfrom
pimoroni:gc-wordaligned
Draft

Gadgetoid wants to merge 3 commits into
micropython:masterfrom
pimoroni:gc-wordaligned

Conversation

@Gadgetoid

Copy link
Copy Markdown
Contributor

Keep the packed 2-bit allocation table but read and write it a 32-bit word (16 blocks) at a time in the hot loops:

  • gc_sweep_free_blocks classifies a word of blocks in registers, with fast paths for all-free and full-tail-run words (one memset).
  • gc_deal_with_stack_overflow finds marked heads with a word scan and ctz rather than testing every block.
  • the gc_alloc free search skips fully-occupied words 16 blocks at a time while keeping the per-byte path for the common near-resume hit, so worst-case allocation latency drops without an alloc-throughput cost.

The table is rounded to a whole number of scan words and its start is word-aligned. Representation and table memory are unchanged (2 bits per block).

This is a broad, less band-aidy application of the technique in #19363 which is probably a better thing to focus on.

Testing

Fuzz and A/B testing between Unix and the RP2 port. Not sufficient to fully back this approach.

Gives up to a ~4.4x speedup on collection on an RP2350 (Pico LiPo 2, with PSRAM and split heap enabled), with no regressions:

workload master gc-wordaligned speedup
DEAD-buffer collect, 4 MB (sweep-bound) 52654 us 12016 us ~4.4x
DEAD-buffer collect, 1 MB 13544 us 3285 us ~4.1x
collect 3000 live lists (stack-overflow path) 78483 us 18389 us ~4.3x
collect during churn, large heap (table-scan) 11672 us 2671 us ~4.4x
worst-case GC pause (tail latency) 1115 us 328 us ~3.4x
GC floor (fixed per-collect) 514 us 458 us lower
allocation throughput (per-alloc churn) 9222 ns 9371 ns ~flat (no regression)

Less impressive:

workload master gc-wordaligned speedup
live-buffer collect (mark-bound), slope 131.1 us/KB 122.7 us/KB ~1.07x

Live-buffer collection is dominated by marking the buffer, which word-aligning the table doesn't touch - so it only shaves the sweep portion (~7%). This is what #19367 aims to address (skip uninteresting buffers).

Trade-offs and Alternatives

I think it goes without saying that completely re-writing GC in the name of performance is not a task to be approached lightly.

Generative AI

I used generative AI tools when creating this PR, but a human has checked the
code and is responsible for the code and the description above.

@codecov

codecov Bot commented Jul 16, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 98.59%. Comparing base (0fd6c57) to head (b393603).

Additional details and impacted files
@@           Coverage Diff           @@
##           master   #19476   +/-   ##
=======================================
  Coverage   98.58%   98.59%           
=======================================
  Files         182      182           
  Lines       23322    23353   +31     
  Branches        5        5           
=======================================
+ Hits        22993    23024   +31     
  Misses        328      328           
  Partials        1        1           
Flag Coverage Δ
unix-coverage-32bit 98.59% <100.00%> (+<0.01%) ⬆️
unix-coverage-64bit 98.52% <100.00%> (+<0.01%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@github-actions

github-actions Bot commented Jul 16, 2026 •

Copy link
Copy Markdown

Code size report:

Reference:  all: Bump version to 1.29.0. [0fd6c57]
Comparison: py/gc: Fix the word-aligned allocation-table scan on big-endian hosts. [merge of b393603]
  mpy-cross:  +160 +0.042% 
   bare-arm:    +0 +0.000% 
minimal x86:  +201 +0.107% 
   unix x64:  +192 +0.022% standard
      stm32:   +64 +0.016% PYBV10
      esp32:  +236 +0.013% ESP32_GENERIC
     mimxrt:   +64 +0.016% TEENSY40
        rp2:  +144 +0.015% RPI_PICO_W
       samd:   +64 +0.023% ADAFRUIT_ITSYBITSY_M4_EXPRESS
  qemu rv32:  +140 +0.031% VIRT_RV32

@dpgeorge dpgeorge added the py-core Relates to py/ directory in source label Jul 17, 2026
@dpgeorge

Copy link
Copy Markdown
Member

I'm surprised how much code size this costs. You can see from the CI that a small samd board no longer fits in flash.

Either we need to be able to disable this optimisation at compile-time, or maybe we do indeed need two separate implementations of the GC: minimal vs fast.

@Gadgetoid

Copy link
Copy Markdown
Contributor Author

I'm surprised how much code size this costs. You can see from the CI that a small samd board no longer fits in flash.

Smells like something new is being pulled in that wasn't before- might have to poke into that! Even the RP2 numbers are quite surprising, but the instances of +2k are downright suspicious.

@dpgeorge

Copy link
Copy Markdown
Member

Those unix port / qemu_mips CI failures seem to be due to the changes in this PR...

@Gadgetoid

Gadgetoid commented Jul 17, 2026 •

Copy link
Copy Markdown
Contributor Author

Update: Much more realistic sizes. Many ports were unrolling a loop in GC that exploded out to ~2k.

SAMD is still tight though, clearly doesn't take much to push it over the edge.

WebAssembly test failures seem to be based on very specific memory checks.

Looking into the others as I get time.

qemu_mips is a spectacularly obvious one in hindsight: endianness. Gets me time every.

(I really ought to get myself some more hardware and build capacity locally 😭)

Keep the packed 2-bit allocation table but read and write it a 32-bit
word (16 blocks) at a time in the hot loops:

- gc_sweep_free_blocks classifies a word of blocks in registers, with
  fast paths for all-free and full-tail-run words (one memset).
- gc_deal_with_stack_overflow finds marked heads with a word scan and
  ctz rather than testing every block.
- the gc_alloc free search skips fully-occupied words 16 blocks at a
  time while keeping the per-byte path for the common near-resume hit,
  so worst-case allocation latency drops without an alloc-throughput
  cost.

The table is rounded to a whole number of scan words and its start is
word-aligned. Representation and table memory are unchanged (2 bits per
block).

Signed-off-by: Phil Howard <[email protected]>
The mixed-word loop in gc_sweep_free_blocks has a compile-time-constant
trip count, so at -O3 (and at -O2 once the vectoriser is enabled) the
compiler fully unrolls it, inflating the function from ~230 bytes to
~2.4 KB. That is enough to push tight targets (e.g. the samd21 boards)
over their flash budget. Pin the loop with #pragma GCC unroll 1; the
word-at-a-time fast paths above it are unaffected.

Signed-off-by: Phil Howard <[email protected]>
The sweep and stack-overflow scans read the allocation table a 32-bit
word at a time and map word bit 2*k to block w*16+k, which only holds
when the four table bytes load little-endian. On a big-endian host the
bytes load reversed, so the sweep frees/keeps the wrong blocks and the
overflow scan re-marks the wrong ones, corrupting the heap (seen as
widespread failures on the big-endian qemu_mips CI target).

The table is logically little-endian, so normalise each scanned word
with MP_LE32TOH on load and MP_HTOLE32 on store; both are no-ops on
little-endian targets. Add the MP_HTOLE16/32 and MP_LE16TOH/32TOH
helpers alongside the existing MP_HTOBE ones to complete the set. The
free-search word skip is a symmetric occupancy test and is unaffected.

Signed-off-by: Phil Howard <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

py-core Relates to py/ directory in source

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants