Skip to content

Add no-op uses to anchor alloca on Wasm#5174

Closed
QuantumSegfault wants to merge 7 commits into
ldc-developers:masterfrom
QuantumSegfault:wasm-gc
Closed

Add no-op uses to anchor alloca on Wasm#5174
QuantumSegfault wants to merge 7 commits into
ldc-developers:masterfrom
QuantumSegfault:wasm-gc

Conversation

@QuantumSegfault

@QuantumSegfault QuantumSegfault commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

On WebAssembly, a working conservative GC is currently blocked on being able to see pointers that might be held somewhere outside of Wasm's linear memory.

Wasm has both an infinite set of "locals" (~registers), AND it's own value stack (Wasm is a stack machine). Neither of these are easily introspectable from arbitrary code. Unlike native platforms, we can't just spill all the registers onto the stack and be done with it.

Furthermore, due largely to union, there may be pointers smuggled in types other than the appropriate i32 on wasm32 or i64 on wasm64. E.g. union U { double d; void* p } holds a pointer in what LLVM sees as just a double (and becomes Wasm f64).


To work around these limitations, this PR adds some more code to alloca generation (DtoAlloca*)

Whenever an alloca is created for a D type that hasPointers, a call to an inline assembly nop is inserted, passing the alloca along as a parameter with register/r constraint. This inhibits SROA and mem2reg, forcing the value to remain on the stack. Rather than spilling things back, we simply force them to stay. Otherwise the nop is cheap if not free, and tools like wasm-opt can safely clean it up after.

LLVM assumes captures on the pointer by default, which makes sure that any stores are visible before calls (which LLVM will assume might read the pointer). When available (>= LLVM 21), captures(read_provenance) is specified to avoid forcing reload after such calls (it is assumed the D does not use a moving GC, so we can avoid this pessimization; e.g. store forwarding optimizations still work across calls).

These calls are not inserted on @nogc functions (and in nogc compilations, e.g. betterC). This relies on the transitivity of @nogc. If a function is @nogc, it may not call non-@nogc functions, which we assume to mean GC.collect will never be invoked.

@QuantumSegfault

Copy link
Copy Markdown
Contributor Author

@kinke
@thewilsonator

Comment thread gen/abi/generic.h Outdated
@JohanEngelen

Copy link
Copy Markdown
Member

On WebAssembly, a working conservative GC is currently blocked on being able to see pointers that might be held somewhere outside of Wasm's linear memory.
.....

Thanks for the extensive explanation and rationale. The whole text should be added to the code as documentation, so it won't get lost!

@JohanEngelen

Copy link
Copy Markdown
Member

A pass is added remove _d_stack_gcroot calls after the optimizations are done. This means running non-LDC opt on the results of --output-ll will break the GC.

Perhaps emit a warning when WASM target and LTO are specified together?

@QuantumSegfault

Copy link
Copy Markdown
Contributor Author

A pass is added remove _d_stack_gcroot calls after the optimizations are done. This means running non-LDC opt on the results of --output-ll will break the GC.

Perhaps emit a warning when WASM target and LTO are specified together?

Oh, that's a good point. I hadn't though of that...

If LTO can strip those calls away successfully (since it's a empty one) then I don't need this. I'll see if I can maybe disable on LTO (and it will still do away with the calls).

@QuantumSegfault
QuantumSegfault force-pushed the wasm-gc branch 2 times, most recently from c75d990 to ffe3d3f Compare July 7, 2026 05:34
@kinke

kinke commented Jul 7, 2026

Copy link
Copy Markdown
Member

Thx for the work! This approach is very intrusive (not nicely isolated for wasm only); I guess there'll be LLVM additions in the future to make this easier, for all other languages with builtin GC. Some refs I've found after a quick search:

An LLVM pass like the last one might be a way forward for now if you don't wanna wait. We could presumably attach IR metadata to IR types, to store the has-potential-GC-pointers flag (for wasm only).

@QuantumSegfault

Copy link
Copy Markdown
Contributor Author

Thx for the work! This approach is very intrusive (not nicely isolated for wasm only); I guess there'll be LLVM additions in the future to make this easier, for all other languages with builtin GC. Some refs I've found after a quick search:

* https://v8.dev/blog/wasm-gc-porting#gc-references-on-the-stack

* [Proposal: Linear-Memory GC-Root Marking WebAssembly/design#1459](https://github.com/WebAssembly/design/issues/1459)

* https://github.com/WebAssembly/binaryen/blob/main/src/passes/SpillPointers.cpp

An LLVM pass like the last one might be a way forward for now if you don't wanna wait. We could presumably attach IR metadata to IR types, to store the has-potential-GC-pointers flag (for wasm only).

Yeah, I've seen those. But they are pretty far off right now. Stack switching is closer, but that won't let us read. And SpillPointers is nice, but union ruins this for us.

But your idea is an interesting one. I had considered something to that effect, but couldn't figure out the union situation. But that just might be the solution (and a less error prone one at that).

It might take two passes. One that takes those unions (due to the opaqueness of their type; some metadata could work, but it has to be acted upon early in the pipeline before it is lost) and forces them to stay on the stack like what I did here.

Then a later pass can take IR-level ptr (or aggregates containing vreg) that are live across a call, and volatile store them to a new alloca with a short lifetime (so hopefully the stack allocator will be able to reuse space). This can happen after SROA and other optimizations, and might actually decrease the negative impact, if done right.

@JohanEngelen

Copy link
Copy Markdown
Member

A pass is added remove _d_stack_gcroot calls after the optimizations are done. This means running non-LDC opt on the results of --output-ll will break the GC.

Perhaps emit a warning when WASM target and LTO are specified together?

Oh, that's a good point. I hadn't though of that...

If LTO can strip those calls away successfully (since it's a empty one) then I don't need this. I'll see if I can maybe disable on LTO (and it will still do away with the calls).

But you won't control when LTO's "opt" is removing such calls; it might do more optimization afterward.

Just to be clear, the case I mean is:
LDC generates bitcode for LTO
LTO is done by lld, thus no special passes possible.

This means running non-LDC opt on the results of --output-ll will break the GC.

It is almost exactly this case, where bc is outputted into an object file instead of textual IR.

@QuantumSegfault

Copy link
Copy Markdown
Contributor Author

But you won't control when LTO's "opt" is removing such calls; it might do more optimization afterward.

Yeah, I understood that. And what I'm saying is if we know we passed -flto and LDC is going to produce bitcode, we just don't run the stripping pass, and instead rely on LTO to clean it up.

But it might not matter if I completely change the approach here...

@JohanEngelen

Copy link
Copy Markdown
Member

But you won't control when LTO's "opt" is removing such calls; it might do more optimization afterward.

Yeah, I understood that. And what I'm saying is if we know we passed -flto and LDC is going to produce bitcode, we just don't run the stripping pass, and instead rely on LTO to clean it up.

LTO could remove the call, and then do more optimzation, no?

But it might not matter if I completely change the approach here...

:-)

@QuantumSegfault

Copy link
Copy Markdown
Contributor Author

But you won't control when LTO's "opt" is removing such calls; it might do more optimization afterward.

Yeah, I understood that. And what I'm saying is if we know we passed -flto and LDC is going to produce bitcode, we just don't run the stripping pass, and instead rely on LTO to clean it up.

LTO could remove the call, and then do more optimzation, no?

But it might not matter if I completely change the approach here...

:-)

...

-_-

Right...well that's not going to work then...

@QuantumSegfault

Copy link
Copy Markdown
Contributor Author

Ok, I figured out how to work around that issue.

call void asm "nop", "r"(ptr captures(read_provenance) %the_alloca)

Since it's inline assembly, it can't be reasoned about by LLVM. The captures constraint helps limit the pessimization as before. And having some extra nop should have a pretty minimal impact on everything (zero if you run wasm-opt to clean it up).


Unfortunately, after some thought, I don't think we can rely on metadata attached to the type for this. It'd probably work for unions, but void[N] causes problems (which gets "inlined" as [N x i8]; maybe if we used an alias with metadata attached instead of i8 as the array element type...).

@QuantumSegfault QuantumSegfault changed the title Add _d_stack_gcroot to anchor alloca on Wasm Add no-op uses to anchor alloca on Wasm Jul 8, 2026
@QuantumSegfault

QuantumSegfault commented Jul 8, 2026

Copy link
Copy Markdown
Contributor Author

We could presumably attach IR metadata to IR types, to store the has-potential-GC-pointers flag (for wasm only).

Nope. Doesn't look like you can attach IR metadata to types.

Only global declarations (global, constant, functions) and instructions.

So the metadata would have to be attached to the alloca anyway.

@QuantumSegfault

Copy link
Copy Markdown
Contributor Author

If this is too invasive...I can't really think of much else (that isn't either harmfully conservative or too imprecise/leaky).

We need some way to know if there are hidden pointers in void[N] or a union that isn't visible in the LLVM IR type of the value/allocation.

@kinke

kinke commented Jul 8, 2026

Copy link
Copy Markdown
Member

Instead of tackling this at the DtoAlloca* helpers level, I'd suggest handling the param and local allocas in defineParameters() and DtoVarDeclaration(). That should make this way less intrusive.

@kinke

kinke commented Jul 8, 2026

Copy link
Copy Markdown
Member

And wrt. to the inline-asm dummy-use: I've just seen https://llvm.org/docs/LangRef.html#llvm-fake-use-intrinsic, that might do it as well.

@JohanEngelen

Copy link
Copy Markdown
Member

Instead of tackling this at the DtoAlloca* helpers level, I'd suggest handling the param and local allocas in defineParameters() and DtoVarDeclaration(). That should make this way less intrusive.

But won't we then miss alloca's that happen in other places?

It's an intrusive change, but only because DtoAlloca* does not know the D-type of what is being allocated; if the type would already be known inside DtoAlloca*, the change would be very small. Would it not be best to first create that situation of simply adding the d-type to DtoAlloca* function signature (which may come in handy for other things in the future). And then as 2nd step add the Wasm bit inside DtoAlloca*?

@kinke

kinke commented Jul 8, 2026

Copy link
Copy Markdown
Member

We use helper allocas in various places, like the ABI shenanigans for reinterpret-casts etc. There is no need to handle all those allocas. All we need to care about are params and locals (incl. temporaries), and keeping them on the stack for wasm. Those can be taken care of with presumably a few lines in the 2 functions I've mentioned. Then there are probably a few more allocas, like non-GC closure frames, the implicit this parameter, stack-promoted scope class instances etc., which might need to be handled separately. And the GC2Stack pass probably.

I just don't wanna have that flag invade all Dto[Raw]Alloca* usages, because of an exotic target (edit: or rather, an exotic feature - GC - of that target, which surely requires other work to get it somewhat working) and its (current) limitations. I doubt we'll have such an alloca-'pinning' method still in 5 years.

@QuantumSegfault

Copy link
Copy Markdown
Contributor Author

We use helper allocas in various places, like the ABI shenanigans for reinterpret-casts etc. There is no need to handle all those allocas. All we need to care about are params and locals (incl. temporaries), and keeping them on the stack for wasm. Those can be taken care of with presumably a few lines in the 2 functions I've mentioned. Then there are probably a few more allocas, like non-GC closure frames, the implicit this parameter, stack-promoted scope class instances etc., which might need to be handled separately. And the GC2Stack pass probably.

I just don't wanna have that flag invade all Dto[Raw]Alloca* usages, because of an exotic target and its (current) limitations. I doubt we'll have such an alloca-'pinning' method still in 5 years.

Okay...I see what you are getting at. Thanks for the explanation.

An easy first step I suppose would be to just lift inserting the pinning to each call-site. Then whittle it down from there.

@kinke

kinke commented Jul 8, 2026

Copy link
Copy Markdown
Member

FWIW, I was thinking of starting with something like this:

diff --git a/gen/functions.cpp b/gen/functions.cpp
index 023c264b1a..f978fbd491 100644
--- a/gen/functions.cpp
+++ b/gen/functions.cpp
@@ -841,6 +841,10 @@ void defineParameters(IrFuncTy &irFty, VarDeclarations &parameters) {
         // Let the ABI transform the parameter back to an lvalue.
         irparam->value =
             irFty.getParamLVal(paramType, llArgIdx, irparam->value);
+
+        if (auto alloca = dyn_cast<AllocaInst>(irparam->value)) {
+          pinAllocaForWasm(alloca, paramType);
+        }
       }
 
       irparam->value->setName(vd->ident->toChars());
diff --git a/gen/llvmhelpers.cpp b/gen/llvmhelpers.cpp
index 655cb318b9..af886cc490 100644
--- a/gen/llvmhelpers.cpp
+++ b/gen/llvmhelpers.cpp
@@ -924,6 +924,8 @@ void DtoVarDeclaration(VarDeclaration *vd) {
       // point it is declared
       gIR->funcGen().localVariableLifetimeAnnotator.addLocalVariable(
           allocainst, DtoConstUlong(size(type)));
+
+      pinAllocaForWasm(allocainst, type);
     }
   }
 

This should handle normal params and locals.

@QuantumSegfault

Copy link
Copy Markdown
Contributor Author

Okay, I've stripped it back down and refactored it.

I haven't tested it yet. I'm pretty sure there are going to be some more pins needed. But it does seem cleaner so far.

@QuantumSegfault

Copy link
Copy Markdown
Contributor Author

Ah...we actually have a bigger problem that brings this whole idea down.

void* retPtr();
void takePtrs(void*,void*);
void test() {
    takePtrs(retPtr(), retPtr()+4);
}

Unoptimized:

define void @_D9test_libc4testFZv() #0 {
  %1 = call ptr @_D9test_libc6retPtrFZPv() #1     ; [#uses = 1]
  %2 = call ptr @_D9test_libc6retPtrFZPv() #1     ; [#uses = 1]
  %3 = getelementptr inbounds i8, ptr %2, i32 4   ; [#uses = 1, type = ptr]
  call void @_D9test_libc8takePtrsFPvQcZv(ptr %1, ptr %3) #1
  ret void
}

The alloca we have aren't enough. Pinning alloca isn't enough...now we need a spill pass anyway. Probably both. One to handle hidden ptrs in allocas, and the other to spill everything else that LLVM IR CAN see...

I need to think about this more...

@kinke

kinke commented Jul 9, 2026

Copy link
Copy Markdown
Member

But it does seem cleaner so far.

Yep, looks much better to me!

Ah...we actually have a bigger problem that brings this whole idea down.

Right :/ - we only get temporaries (from the frontend) if the type is a non-POD AFAIK, not for pointers/class refs. [Once we are in the takePtrs callee, the 2 pointers would be in their allocas - assuming takePtrs() is a D function.] Dummy-spilling in DtoArgument() might be an option, for function calls at least.

Comment thread gen/classes.cpp
} else if (newexp->onstack) {
mem = DtoRawAlloca(irClass->getLLStructType(), tc->sym->alignsize,
".newclass_alloca");
InsertWasmAllocaPin(mem, tc);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

tc represents a class ref, which is always treated as a potential GC pointer itself. We'd need to query whether the class instance might contain GC pointers in some field, analogous to the has-pointers TypeInfo flag:

ldc/ir/irclass.cpp

Lines 327 to 334 in 2b47d40

for (ClassDeclaration *pc = cd; pc; pc = pc->baseClass) {
for (VarDeclaration *vd : pc->fields) {
// printf("vd = %s %s\n", vd->kind(), vd->toChars());
if (hasPointers(vd)) {
return flags;
}
}
}

Comment thread gen/functions.cpp
// Let the ABI transform the parameter back to an lvalue.
irparam->value =
irFty.getParamLVal(paramType, llArgIdx, irparam->value);
InsertWasmAllocaPin(irparam->value, paramType);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[I don't think we are guaranteed to always get an alloca here, just wrt. the llvm::isa<llvm::AllocaInst> assertion. Not that a dummy-use of a non-alloca would be problematic, just the assertion might be.]

@QuantumSegfault

Copy link
Copy Markdown
Contributor Author

Perhaps something like this would be preferred: 4069999

A spill pass to happen after all optimizations. It works at the moment, but is extremely conservative (any vreg that crosses a call triggers it). With some work, it can be refined to only attack potential pointers. If done right, it has the opportunity to be more precise (less pezzimizing) than forcing entire alloca's on to the stack.

@JohanEngelen

Copy link
Copy Markdown
Member

Perhaps something like this would be preferred: 4069999

A spill pass to happen after all optimizations. It works at the moment, but is extremely conservative (any vreg that crosses a call triggers it). With some work, it can be refined to only attack potential pointers. If done right, it has the opportunity to be more precise (less pezzimizing) than forcing entire alloca's on to the stack.

I think the most important thing is to get GC working correctly first, then start optimizing/improving from there.

@QuantumSegfault

Copy link
Copy Markdown
Contributor Author

I think the most important thing is to get GC working correctly first, then start optimizing/improving from there.

Fair, but after working on #5178, I've realized that this approach doesn't work as is. At least, not without creating more alloca for all sorts of expressions/instructions we emit directly.

Take this simple example:

void usePtr(void*);
void* getPtr();
size_t maybeTriggetGCForWhateverReason();
void test() {
    usePtr(cast(void*)(cast(size_t)getPtr() & maybeTriggetGCForWhateverReason()));
}

Even at -O0, we have:

define void @_D9test_libc4testFZv() #0 {
  %1 = call ptr @_D9test_libc6getPtrFZPv() #1     ; [#uses = 1]
  %2 = ptrtoint ptr %1 to i32                     ; [#uses = 2]
  %3 = call i32 @_D9test_libc31maybeTriggetGCForWhateverReasonFZk() #1 ; [#uses = 1]
  %4 = and i32 %2, %3                             ; [#uses = 1]
  %5 = inttoptr i32 %4 to ptr                     ; [#uses = 2]
  call void @_D9test_libc6usePtrFPvZv(ptr %5) #1
  ret void
}

Even if we were to say, dump all parameters to alloca, we'd only being storing %5 after the fact. If getPtr allocates something (and only returns the value), and maybeTriggerGCForWhateverReason triggers a collection, we'd free %1 prematurely, potentially.

Vs. with my other PR:

define void @_D9test_libc4testFZv() #0 {
  %stackSpill.unnamedVreg = alloca ptr, align 4   ; [#uses = 1, size/byte = 4]
  %stackSpill.unnamedVreg1 = alloca i32, align 4  ; [#uses = 1, size/byte = 4]
  %1 = call ptr @_D9test_libc6getPtrFZPv() #1     ; [#uses = 1]
  %2 = ptrtoint ptr %1 to i32                     ; [#uses = 2]
  store volatile i32 %2, ptr %stackSpill.unnamedVreg1, align 4
  %3 = call i32 @_D9test_libc31maybeTriggetGCForWhateverReasonFZk() #1 ; [#uses = 1]
  %4 = and i32 %2, %3                             ; [#uses = 1]
  %5 = inttoptr i32 %4 to ptr                     ; [#uses = 2]
  store volatile ptr %5, ptr %stackSpill.unnamedVreg, align 4
  call void @_D9test_libc6usePtrFPvZv(ptr %5) #1
  ret void
}

No issues. And for now, it errs on the side of caution for the most part:

define void @_D9test_libc4testFZv() local_unnamed_addr #0 {
  %stackSpill.unnamedVreg = alloca ptr, align 4   ; [#uses = 1, size/byte = 4]
  %stackSpill.unnamedVreg1 = alloca i32, align 4  ; [#uses = 1, size/byte = 4]
  %stackSpill.unnamedVreg2 = alloca i32, align 4  ; [#uses = 1, size/byte = 4]
  %1 = tail call ptr @_D9test_libc6getPtrFZPv() #1 ; [#uses = 1]
  %2 = ptrtoint ptr %1 to i32                     ; [#uses = 2]
  store volatile i32 %2, ptr %stackSpill.unnamedVreg1, align 4
  %3 = tail call i32 @_D9test_libc31maybeTriggetGCForWhateverReasonFZk() #1 ; [#uses = 2]
  store volatile i32 %3, ptr %stackSpill.unnamedVreg2, align 4
  %4 = tail call i32 @_D9test_libc31maybeTriggetGCForWhateverReasonFZk() #1 ; [#uses = 1]
  %5 = add i32 %4, %3                             ; [#uses = 1]
  %6 = and i32 %5, %2                             ; [#uses = 1]
  %7 = inttoptr i32 %6 to ptr                     ; [#uses = 2]
  store volatile ptr %7, ptr %stackSpill.unnamedVreg, align 4
  tail call void @_D9test_libc6usePtrFPvZv(ptr %7) #1
  ret void
}

Even though with cast(void*)(cast(size_t)a() & (b() + c())), neither b or c are pointers, it still crawls through, marks, both, and spills b so it isn't collected during c.


Though in this example, I guess dumping %1 as the return value would probably suffice (and %7, as an argument/parameter)...

Maybe it's a narrower surface than I thought. Since expression chains are trees. Maybe just dumping (rets and args) and load of pointer-like types and inttoptr...maybe that would be enough.

@QuantumSegfault

Copy link
Copy Markdown
Contributor Author

Even if I can correctly track down everywhere in the front-end that needs spilling and anchoring, it feels that #5178 is better contained, less likely to miss things we maybe didn't think off (if we're really paranoid, we could just mark ALL vreg as potential pointers; act more like native platforms with stack & register spilling), and interferes with the optimizer less (pinning alloca optimizations won't happen on aggregates, even if only a small part of a large struct is used as a pointer).

@JohanEngelen

Copy link
Copy Markdown
Member

I think the most important thing is to get GC working correctly first, then start optimizing/improving from there.

Fair, but after working on #5178, I've realized that this approach doesn't work as is. At least, not without creating more alloca for all sorts of expressions/instructions we emit directly.

I didn't mean to criticise any of your PRs, but instead wanted to give you some support that it is OK if the implementation has "poor" or pessimistic performance, whichever you decide on in the end. :)

@QuantumSegfault

Copy link
Copy Markdown
Contributor Author

Closing in favor of #5178 (could always be reopened...)

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