Skip to content

runtime: use precise layouts with Boehm GC - #5686

Open
jakebailey wants to merge 2 commits into
tinygo-org:devfrom
jakebailey:boehm-gc-shapes
Open

jakebailey wants to merge 2 commits into
tinygo-org:devfrom
jakebailey:boehm-gc-shapes

Conversation

@jakebailey

Copy link
Copy Markdown
Member

Providing exact GC shapes to Boehm should make it faster.

@deadprogram

Copy link
Copy Markdown
Member

Thanks for working on this @jakebailey! Here are some notes, edited from an automated review.

I built both branches locally and measured on linux/amd64 and wasip1.

  1. Mark-heavy heaps get much faster. With 200000 objects of 1 pointer and 30 non-pointer words each, and 20 forced collections, the time went from 225/235/230/257 ms to 163/147/122/131 ms. This is the case the change targets and it works well.

  2. Allocation-heavy pointer-dense code gets slower. A binary tree of nodes with 2 pointers and 4 ints, 8 runs each, went from 237 ms minimum and 252 ms median to 266 ms minimum and 275 ms median, near 9% slower. Two possible causes are the hash lookup in tinygo_runtime_bdwgc_make_descriptor that runs for each allocation, and the one word that GC_malloc_explicitly_typed adds for the descriptor, which can move an object to the next size class.

  3. Binary size increases near 11% for each Boehm build. For testdata/gc.go with -gc=boehm, wasip1 went from 475765 to 526270 bytes, and linux/amd64 went from 530128 to 591128 bytes. This is the cost of typd_mlc.c and finalize.c.

  4. The descriptor cache can be Go code. Approximately 100 lines of new C in gc_boehm.c do the work of a hash map. The .c file is there only for C function pointer signatures, and nothing in the cache needs C. If you key the cache on the layout global, or cache next to it, you can also remove the hash lookup from each allocation.

  5. A descriptor value of 0 is not only a failure. GC_make_descriptor returns 0 when the bitmap has no set bits, in lib/bdwgc/typd_mlc.c. The comment says "Descriptor construction can fail under memory pressure", which does not include that case. createObjectLayout returns NoPtrs for pointer-free types, so the case looks unreachable now, but the comment can say this.

  6. The layout unit is pointer alignment, not pointer size. compiler/compiler.go uses PrefTypeAlignment(dataPtrType) and gc_precise.go uses unsafe.Alignof. This change uses unsafe.Sizeof(uintptr(0)) and sizeof(uintptr_t). The two agree on each Boehm target, so there is no defect, but Alignof keeps the code consistent.

  7. gc_boehm.c declares GC_WORDSZ, GC_set_bit, GC_descr, and the typed prototypes again. lib/bdwgc/include/gc/gc_typed.h already defines all of them. If you include that header, the two copies cannot become different later.

The binary size increase is pretty large IMO...

@jakebailey

Copy link
Copy Markdown
Member Author

I'm measuring a different size increase than you, 4.8% on linux/amd64, 6.5% on wasip1, but I'll look into the rest...

@jakebailey

Copy link
Copy Markdown
Member Author

I also do not see 9% perf hit, either.

@jakebailey

Copy link
Copy Markdown
Member Author

Ah, the measurement difference is because dev already got a little larger with the stack unwinding changes.

@jakebailey

Copy link
Copy Markdown
Member Author

The descriptor cache can be Go code.

I don't think it can; the Go cache would need to allocate, which would be a cycle, as it is itself part of the alloc.

Use Boehm explicitly typed allocations for pointer-containing Go objects.
Cache one descriptor per TinyGo element layout and use typed calloc so
array and slice backing stores repeat that descriptor without
allocation-sized descriptor growth. Keep pointer-free and raw stack
allocations on the existing atomic and conservative paths.

Compile the Boehm bridge only for Boehm builds and use the public BDWGC
headers for its declarations. Build typed allocation and finalization
support because complex typed array descriptors use disappearing links
internally. Bump the Boehm cache version for the changed source and flag
set.

Add regressions showing that integer fields in inline, external, and
repeated layouts do not retain heap objects.
Canonicalize all-pointer layouts so Boehm can use conservative allocation
when it is exact. Bypass trivial allocation wrappers and skip typed-array
setup for scalar objects.
Comment thread targets/wasip1.json
],
"extra-files": [
"src/runtime/asm_tinygowasm.S",
"src/runtime/gc_boehm.c"

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Not sure I understand why these were placed here unconditionally

@deadprogram

deadprogram commented Sep 17, 2026

Copy link
Copy Markdown
Member

Thanks for the corrections @jakebailey, you were right on two of my points. I rebuilt both sides from the merge base 61e5af34 and measured again. Notes below, edited from an automated review.

  1. My size numbers were not calculated correctly. The addition is a fixed cost, not a percentage. For testdata/gc.go, code size goes from 69987 to 78746 bytes on wasip1, and from 77680 to 87409 bytes on linux/amd64. For a program that uses encoding/json, regexp, sort, and strings, code size goes from 679832 to 688582 bytes on wasip1, and from 615795 to 625440 bytes on linux/amd64. That is approximately 8.8 KB on wasip1 and 9.7 KB on linux/amd64 in each case. The percentage only shows the size of the baseline program. The absolute number is the useful one, so please use it and disregard my 11% figure.

  2. My 9% figure was too high, and the regression is narrower than I said. With 12 interleaved runs of each binary, a tree of nodes with 2 pointers and 4 ints goes from a median of 220 ms to 234 ms, which is near 6%. A rank-sum check gives a 0.82 probability that this branch is slower in a given pair of runs, so the effect is real but small.

  3. A tree of nodes with only pointers shows no change. The median goes from 341 ms to 344 ms, and the rank-sum probability is 0.59, which is the same as noise. Your new canonical layout in compiler/llvm.go and the gclayout.Pointer path in alloc remove the cost for this shape completely. The remaining 6% applies only to objects that mix pointer and non-pointer words.

  4. The mark-phase gain is large and it repeats. With 200000 live objects of 1 pointer and 30 non-pointer words each, and 20 forced collections, the median goes from 206 ms to 111 ms across 12 runs.

  5. You are correct that the descriptor cache cannot be a Go map. One small correction only: the obstacle is Go allocation, not Go code. gc_boehm.go already calls libgc_malloc_atomic_uncollectable and libgc_free from Go in allocManual and free, so a hand-written table in package globals would not call alloc and would not cycle. That is probably not any better than the C you have.

  6. If you want to remove the remaining 6%, a one-entry memo of the last layout and descriptor pair may be enough, because allocation sites repeat. I do not suggest that you store the descriptor in the layout global, because createObjectLayout marks those globals constant and the change would move them out of read-only memory.

  7. Config.CFlags now adds -I lib/bdwgc/include for each C file in a -gc=boehm build. loader.go uses config.CFlags(true) for CGo preprocessing, so user CGo packages also receive this include path. A user header below a gc/ directory can then find the bdwgc headers first. The extra file has its own compile step in builder/build.go, so you can give the flag only to gc_boehm.c.

  8. I checked the new layout encoding by hand for 64-bit, 32-bit, and AVR. In each case the value equals gclayout.Pointer, which agrees with the change from 197 to 67 in compiler/testdata/gc.ll. reflectlite returns gclayout.Pointer and gclayout.PointerPair at run time, and alloc accepts both, so the canonical form does not miss those.

Item 7 is probably the only one that might require some action. Otherwise, since Boehm is not the default for any target, the ~9 KB fixed code growth is an opt-in cost paid only by users who ask for it.

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.

2 participants