From 10d51c91a9d413c13aa6741b2c114df798b8839a Mon Sep 17 00:00:00 2001 From: Andy Ayers Date: Sun, 24 May 2026 17:47:45 -0700 Subject: [PATCH 1/4] JIT: skip stack allocation for unused locals Extend escape analysis with a parallel "used" bit. A tracked local is used if it has at least one non-trivial use -- not a copy into another tracked local, and not a use the allocation satisfies statically (compare against null/zero, NULLCHECK, ARR_LENGTH, BOUNDS_CHECK, discarded value, LCL_ADDR as a store destination, non-GC IND/BLK). The relation is closed over the connection graph the same way escapes are. Locals that neither escape nor are used have no externally visible effect. Leave them as heap allocations with reason "[unused]"; the unreferenced helper call is then removed by later liveness/DCE. Stack- allocating these locals instead produces a dead temp whose initialization stores survive, costing code size. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/coreclr/jit/objectalloc.cpp | 137 +++++++++++++++++++++++++++++++- src/coreclr/jit/objectalloc.h | 47 ++++++++++- 2 files changed, 180 insertions(+), 4 deletions(-) diff --git a/src/coreclr/jit/objectalloc.cpp b/src/coreclr/jit/objectalloc.cpp index 2fab92081bb9ce..a6e981e32c47f7 100644 --- a/src/coreclr/jit/objectalloc.cpp +++ b/src/coreclr/jit/objectalloc.cpp @@ -59,6 +59,7 @@ ObjectAllocator::ObjectAllocator(Compiler* comp) , m_initialMaxBlockID(comp->compBasicBlockID) { m_EscapingPointers = BitVecOps::UninitVal(); + m_DefinitelyUsedPointers = BitVecOps::UninitVal(); m_PossiblyStackPointingPointers = BitVecOps::UninitVal(); m_DefinitelyStackPointingPointers = BitVecOps::UninitVal(); m_ConnGraphAdjacencyMatrix = nullptr; @@ -272,6 +273,17 @@ void ObjectAllocator::MarkIndexAsEscaping(unsigned int bvIndex) BitVecOps::AddElemD(&m_bitVecTraits, m_EscapingPointers, bvIndex); } +//------------------------------------------------------------------------------ +// MarkIndexAsUsed : Mark resource as having at least one non-trivial use. +// +// Arguments: +// bvIndex - bv index for the resource +// +void ObjectAllocator::MarkIndexAsUsed(unsigned int bvIndex) +{ + BitVecOps::AddElemD(&m_bitVecTraits, m_DefinitelyUsedPointers, bvIndex); +} + //------------------------------------------------------------------------------ // MarkLclVarAsPossiblyStackPointing : Mark local variable as possibly pointing // to a stack-allocated object. @@ -551,6 +563,7 @@ void ObjectAllocator::DoAnalysis() if (m_bvCount > 0) { m_EscapingPointers = BitVecOps::MakeEmpty(&m_bitVecTraits); + m_DefinitelyUsedPointers = BitVecOps::MakeEmpty(&m_bitVecTraits); m_ConnGraphAdjacencyMatrix = new (m_compiler->getAllocator(CMK_ObjectAllocator)) BitSetShortLongRep[m_bvCount]; // If we are doing conditional escape analysis, we also need to compute dominance. @@ -569,6 +582,14 @@ void ObjectAllocator::DoAnalysis() MarkEscapingVarsAndBuildConnGraph(); ComputeEscapingNodes(&m_bitVecTraits, m_EscapingPointers); + + // Every escaping local is also (by definition) used: the value flows + // somewhere we can't see. Seed the used set with the escape set so that + // the closure propagates correctly through chains that terminate in an + // escape. + // + BitVecOps::UnionD(&m_bitVecTraits, m_DefinitelyUsedPointers, m_EscapingPointers); + ComputeConnGraphClosure(&m_bitVecTraits, m_DefinitelyUsedPointers, "used"); } #ifdef DEBUG @@ -958,6 +979,67 @@ void ObjectAllocator::ComputeEscapingNodes(BitVecTraits* bitVecTraits, BitVec& e } } +//------------------------------------------------------------------------------ +// ComputeConnGraphClosure : Propagate membership over the connection graph. +// +// Arguments: +// bitVecTraits - Bit vector traits +// nodes [in/out] - Initial set of nodes; on return, contains all nodes +// reachable from the initial set via connection-graph edges +// (an edge dst -> src means the value of src flowed into dst, +// so any property of dst also holds for src). +// setName - Human-readable name of the set for JITDUMP output +// +// Notes: +// Mirrors the propagation in ComputeEscapingNodes but for arbitrary attributes +// (currently used for "used" tracking). +// +void ObjectAllocator::ComputeConnGraphClosure(BitVecTraits* bitVecTraits, BitVec& nodes, const char* setName) +{ + BitVec nodesToProcess = BitVecOps::MakeCopy(bitVecTraits, nodes); + + JITDUMP("\nComputing %s closure\n\n", setName); + + bool doOneMoreIteration = true; + BitSetShortLongRep newNodes = BitVecOps::UninitVal(); + unsigned int lclIndex; + + while (doOneMoreIteration) + { + BitVecOps::Iter iterator(bitVecTraits, nodesToProcess); + doOneMoreIteration = false; + + while (iterator.NextElem(&lclIndex)) + { + if (m_ConnGraphAdjacencyMatrix[lclIndex] != nullptr) + { + doOneMoreIteration = true; + + BitVecOps::Assign(bitVecTraits, newNodes, m_ConnGraphAdjacencyMatrix[lclIndex]); + BitVecOps::DiffD(bitVecTraits, newNodes, nodes); + BitVecOps::UnionD(bitVecTraits, nodesToProcess, newNodes); + BitVecOps::UnionD(bitVecTraits, nodes, newNodes); + BitVecOps::RemoveElemD(bitVecTraits, nodesToProcess, lclIndex); + +#ifdef DEBUG + if (!BitVecOps::IsEmpty(bitVecTraits, newNodes)) + { + BitVecOps::Iter newIterator(bitVecTraits, newNodes); + unsigned int newLclIndex; + while (newIterator.NextElem(&newLclIndex)) + { + JITDUMPEXEC(DumpIndex(lclIndex)); + JITDUMP(" causes "); + JITDUMPEXEC(DumpIndex(newLclIndex)); + JITDUMP(" to be %s\n", setName); + } + } +#endif + } + } + } +} + //------------------------------------------------------------------------------ // ComputeStackObjectPointers : Given an initial set of possibly stack-pointing nodes, // and an initial set of definitely stack-pointing nodes, @@ -1167,6 +1249,14 @@ bool ObjectAllocator::CanAllocateLclVarOnStack(unsigned int lclNum, return false; } + if (!IsLclVarUsed(lclNum)) + { + // No non-trivial use; leave as heap and let DCE clean up. + // + *reason = "[unused]"; + return false; + } + if (blockSize != nullptr) { *blockSize = classSize; @@ -1894,6 +1984,8 @@ void ObjectAllocator::AnalyzeParentStack(ArrayStack* parentStack, unsi bool keepChecking = true; bool canLclVarEscapeViaParentStack = true; bool isCopy = true; + bool edgeAddedForLcl = false; + bool isTrivialUse = false; bool const isEnumeratorLocal = lclDsc->lvIsEnumerator; bool isAddress = parentStack->Top()->OperIs(GT_LCL_ADDR); @@ -1901,7 +1993,9 @@ void ObjectAllocator::AnalyzeParentStack(ArrayStack* parentStack, unsi { if (parentStack->Height() <= parentIndex) { + // No parent uses this expression. canLclVarEscapeViaParentStack = false; + isTrivialUse = true; break; } @@ -1912,6 +2006,7 @@ void ObjectAllocator::AnalyzeParentStack(ArrayStack* parentStack, unsi isCopy = false; canLclVarEscapeViaParentStack = true; keepChecking = false; + isTrivialUse = false; JITDUMP("... V%02u ... checking [%06u]\n", lclNum, m_compiler->dspTreeID(parent)); @@ -1941,6 +2036,7 @@ void ObjectAllocator::AnalyzeParentStack(ArrayStack* parentStack, unsi // Add an edge to the connection graph. // AddConnGraphEdgeIndex(dstIndex, lclIndex); + edgeAddedForLcl = true; canLclVarEscapeViaParentStack = false; // If the source of this store is an enumerator local, @@ -1963,10 +2059,29 @@ void ObjectAllocator::AnalyzeParentStack(ArrayStack* parentStack, unsi case GT_GT: case GT_LE: case GT_GE: + { + canLclVarEscapeViaParentStack = false; + + // Comparing against a constant zero just tests nullness. The + // allocation candidate is known non-null, so this folds away. + // + GenTree* const op1 = parent->AsOp()->gtGetOp1(); + GenTree* const op2 = parent->AsOp()->gtGetOp2(); + GenTree* const other = (op1 == tree) ? op2 : op1; + if (other->IsIntegralConst(0)) + { + isTrivialUse = true; + } + break; + } + case GT_NULLCHECK: case GT_ARR_LENGTH: case GT_BOUNDS_CHECK: + // These read statically-known properties (non-null, fixed length). + // BOUNDS_CHECK takes a length, so the local is unlikely to appear here. canLclVarEscapeViaParentStack = false; + isTrivialUse = true; break; case GT_COMMA: @@ -1974,6 +2089,7 @@ void ObjectAllocator::AnalyzeParentStack(ArrayStack* parentStack, unsi { // Left child of GT_COMMA, it will be discarded canLclVarEscapeViaParentStack = false; + isTrivialUse = true; break; } FALLTHROUGH; @@ -2044,13 +2160,14 @@ void ObjectAllocator::AnalyzeParentStack(ArrayStack* parentStack, unsi { if (isAddress) { - // Remember the resource being stored to. + // Pure write through the local's address; does not read its value. // JITDUMP("... store address is local\n"); m_StoreAddressToIndexMap.Set(parent, StoreInfo(lclIndex)); + isTrivialUse = true; } - // The address does not escape + // The address does not escape. // canLclVarEscapeViaParentStack = false; break; @@ -2080,6 +2197,7 @@ void ObjectAllocator::AnalyzeParentStack(ArrayStack* parentStack, unsi else { AddConnGraphEdgeIndex(dstInfo->m_index, lclIndex); + edgeAddedForLcl = true; canLclVarEscapeViaParentStack = false; break; } @@ -2106,6 +2224,7 @@ void ObjectAllocator::AnalyzeParentStack(ArrayStack* parentStack, unsi JITDUMP("... local V%02u.f store\n", dstLclNum); const unsigned dstIndex = LocalToIndex(dstLclNum); AddConnGraphEdgeIndex(dstIndex, lclIndex); + edgeAddedForLcl = true; canLclVarEscapeViaParentStack = false; // Note that we modelled this store in the connection graph @@ -2125,7 +2244,10 @@ void ObjectAllocator::AnalyzeParentStack(ArrayStack* parentStack, unsi // if (!IsTrackedType(parent->TypeGet())) { + // Loading a non-GC value cannot reveal the local's ref identity. + // canLclVarEscapeViaParentStack = false; + isTrivialUse = true; break; } @@ -2138,6 +2260,7 @@ void ObjectAllocator::AnalyzeParentStack(ArrayStack* parentStack, unsi if (!layout->HasGCPtr()) { canLclVarEscapeViaParentStack = false; + isTrivialUse = true; break; } } @@ -2268,6 +2391,16 @@ void ObjectAllocator::AnalyzeParentStack(ArrayStack* parentStack, unsi m_compiler->dspTreeID(parentStack->Top(parentIndex))); MarkLclVarAsEscaping(lclNum); } + + // If this use isn't a copy into another tracked local and isn't trivial, + // record it as a real use. + // + if (!edgeAddedForLcl && !isTrivialUse && !IsIndexUsed(lclIndex)) + { + JITDUMPEXEC(DumpIndex(lclIndex)); + JITDUMP(" first used via [%06u]\n", m_compiler->dspTreeID(parentStack->Top())); + MarkIndexAsUsed(lclIndex); + } } //------------------------------------------------------------------------ diff --git a/src/coreclr/jit/objectalloc.h b/src/coreclr/jit/objectalloc.h index 4f8e6fc20c1748..7af1b11788c980 100644 --- a/src/coreclr/jit/objectalloc.h +++ b/src/coreclr/jit/objectalloc.h @@ -172,8 +172,11 @@ class ObjectAllocator final : public Phase BitVecTraits m_bitVecTraits; unsigned m_unknownSourceIndex; BitVec m_EscapingPointers; - // We keep the set of possibly-stack-pointing pointers as a superset of the set of - // definitely-stack-pointing pointers. All definitely-stack-pointing pointers are in both sets. + // Tracked locals with at least one non-trivial use (see AnalyzeParentStack). + // Locals that neither escape nor are used can stay as heap allocations and + // be removed by later DCE. + BitVec m_DefinitelyUsedPointers; + // The possibly-stack-pointing set is a superset of the definitely-stack-pointing set. BitVec m_PossiblyStackPointingPointers; BitVec m_DefinitelyStackPointingPointers; LocalToLocalMap m_HeapLocalToStackObjLocalMap; @@ -224,6 +227,8 @@ class ObjectAllocator final : public Phase unsigned IndexToLocal(unsigned bvIndex); bool CanLclVarEscape(unsigned int lclNum); bool CanIndexEscape(unsigned int index); + bool IsLclVarUsed(unsigned int lclNum); + bool IsIndexUsed(unsigned int index); void MarkLclVarAsPossiblyStackPointing(unsigned int lclNum); void MarkIndexAsPossiblyStackPointing(unsigned int index); void MarkLclVarAsDefinitelyStackPointing(unsigned int lclNum); @@ -236,10 +241,12 @@ class ObjectAllocator final : public Phase void DoAnalysis(); void MarkLclVarAsEscaping(unsigned int lclNum); void MarkIndexAsEscaping(unsigned int lclNum); + void MarkIndexAsUsed(unsigned int index); void MarkEscapingVarsAndBuildConnGraph(); void AddConnGraphEdge(unsigned int sourceLclNum, unsigned int targetLclNum); void AddConnGraphEdgeIndex(unsigned int sourceIndex, unsigned int targetIndex); void ComputeEscapingNodes(BitVecTraits* bitVecTraits, BitVec& escapingNodes); + void ComputeConnGraphClosure(BitVecTraits* bitVecTraits, BitVec& nodes, const char* setName); void ComputeStackObjectPointers(BitVecTraits* bitVecTraits); bool MorphAllocObjNodes(); void MorphAllocObjNode(AllocationCandidate& candidate); @@ -356,6 +363,42 @@ inline bool ObjectAllocator::CanLclVarEscape(unsigned int lclNum) return CanIndexEscape(LocalToIndex(lclNum)); } +//------------------------------------------------------------------------ +// IsIndexUsed: Returns true iff the resource described by index has +// at least one non-trivial use (see m_DefinitelyUsedPointers). +// +// Arguments: +// index - bv index +// +// Return Value: +// Returns true if so + +inline bool ObjectAllocator::IsIndexUsed(unsigned int index) +{ + return BitVecOps::IsMember(&m_bitVecTraits, m_DefinitelyUsedPointers, index); +} + +//------------------------------------------------------------------------ +// IsLclVarUsed: Returns true iff the local has at least one non-trivial +// use (see m_DefinitelyUsedPointers). +// +// Arguments: +// lclNum - Local variable number +// +// Return Value: +// Returns true if so. Untracked locals conservatively return true (we know +// nothing about their uses; assume they are used). + +inline bool ObjectAllocator::IsLclVarUsed(unsigned int lclNum) +{ + if (!IsTrackedLocal(lclNum)) + { + return true; + } + + return IsIndexUsed(LocalToIndex(lclNum)); +} + //------------------------------------------------------------------------ // MayIndexPointToStack: Returns true iff the resource described by index may // point to a stack-allocated object From 78b794b653062d5d8295fdba0775291e7ac7482f Mon Sep 17 00:00:00 2001 From: Andy Ayers Date: Mon, 25 May 2026 07:31:22 -0700 Subject: [PATCH 2/4] treat non-GC loads through the local as real uses IND/BLK of a non-tracked type doesn't expose the local's ref identity, but it does read the storage. Marking such loads as trivial caused arrays read via INDEX_ADDR and classes read via FIELD_ADDR to be tagged unused and left on the heap. --- src/coreclr/jit/objectalloc.cpp | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/coreclr/jit/objectalloc.cpp b/src/coreclr/jit/objectalloc.cpp index a6e981e32c47f7..e19f3adf3a93ef 100644 --- a/src/coreclr/jit/objectalloc.cpp +++ b/src/coreclr/jit/objectalloc.cpp @@ -2244,10 +2244,10 @@ void ObjectAllocator::AnalyzeParentStack(ArrayStack* parentStack, unsi // if (!IsTrackedType(parent->TypeGet())) { - // Loading a non-GC value cannot reveal the local's ref identity. + // Loading a non-GC value cannot reveal the local's ref identity, + // but it is a real read of the storage, so not a trivial use. // canLclVarEscapeViaParentStack = false; - isTrivialUse = true; break; } @@ -2260,7 +2260,6 @@ void ObjectAllocator::AnalyzeParentStack(ArrayStack* parentStack, unsi if (!layout->HasGCPtr()) { canLclVarEscapeViaParentStack = false; - isTrivialUse = true; break; } } From f1a4caa3ea627b517d7e3914d1d5038d1b8d3580 Mon Sep 17 00:00:00 2001 From: Andy Ayers Date: Mon, 25 May 2026 10:10:57 -0700 Subject: [PATCH 3/4] JIT: drop NULLCHECK+side-effect-free-allocator pairs in morph When a NULLCHECK targets a helper call whose helper-properties model treats as non-throwing and side-effect-free (e.g., NEWSFAST), bash the whole NULLCHECK to NOP rather than wrapping the call in a UnusedValNode. This matches the IR shape that stack allocation produces, so downstream VN/CSE see no allocator artifacts where the [unused] rule kept a call. Uses HasSideEffects(this) (default ignoreExceptions=false) instead of the cached GTF_SIDE_EFFECT bit so the smart helper-properties predicate is consulted; calls that may throw real exceptions (e.g., NEWARR with non-constant length / OverflowException) are preserved as unused values. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/coreclr/jit/morph.cpp | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/coreclr/jit/morph.cpp b/src/coreclr/jit/morph.cpp index 4645bf4d052b25..d8a5ea48369c70 100644 --- a/src/coreclr/jit/morph.cpp +++ b/src/coreclr/jit/morph.cpp @@ -8317,7 +8317,19 @@ GenTree* Compiler::fgMorphSmpOp(GenTree* tree, bool* optAssertionPropDone) if (opts.OptimizationEnabled() && !tree->OperMayThrow(this)) { JITDUMP("\nNULLCHECK on [%06u] will always succeed\n", dspTreeID(op1)); - if ((op1->gtFlags & GTF_SIDE_EFFECT) != 0) + + // If op1 is a helper call with no observable side effects (e.g., an + // allocator helper without GTF_CALL_M_ALLOC_SIDE_EFFECTS that the JIT + // models as non-throwing, like NEWSFAST), we can drop the entire + // NULLCHECK and the call. This matches the IR shape produced by stack + // allocation, allowing downstream VN/CSE to treat the surrounding code + // as if no allocator call were present. We rely on HasSideEffects's + // helper-properties model rather than the cached GTF_SIDE_EFFECT bit, + // which is conservative for calls. + const bool op1HasSideEffects = + op1->IsCall() ? op1->AsCall()->HasSideEffects(this) : ((op1->gtFlags & GTF_SIDE_EFFECT) != 0); + + if (op1HasSideEffects) { tree = gtUnusedValNode(op1); tree->SetMorphed(this, /* doChildren */ true); From 8703c2741affc02dd7d0bc1040080f6b44e3ac80 Mon Sep 17 00:00:00 2001 From: Andy Ayers Date: Tue, 26 May 2026 19:18:35 -0700 Subject: [PATCH 4/4] ensure we don't lose side-effecting args Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/coreclr/jit/morph.cpp | 34 ++++++++++++++++++++++++---------- 1 file changed, 24 insertions(+), 10 deletions(-) diff --git a/src/coreclr/jit/morph.cpp b/src/coreclr/jit/morph.cpp index 1cb8c7736f5d78..4d30447e5e41a5 100644 --- a/src/coreclr/jit/morph.cpp +++ b/src/coreclr/jit/morph.cpp @@ -8273,16 +8273,30 @@ GenTree* Compiler::fgMorphSmpOp(GenTree* tree, bool* optAssertionPropDone) // If op1 is a helper call with no observable side effects (e.g., an // allocator helper without GTF_CALL_M_ALLOC_SIDE_EFFECTS that the JIT - // models as non-throwing, like NEWSFAST), we can drop the entire - // NULLCHECK and the call. This matches the IR shape produced by stack - // allocation, allowing downstream VN/CSE to treat the surrounding code - // as if no allocator call were present. We rely on HasSideEffects's - // helper-properties model rather than the cached GTF_SIDE_EFFECT bit, - // which is conservative for calls. - const bool op1HasSideEffects = - op1->IsCall() ? op1->AsCall()->HasSideEffects(this) : ((op1->gtFlags & GTF_SIDE_EFFECT) != 0); - - if (op1HasSideEffects) + // models as non-throwing, like NEWSFAST), we can drop the call itself. + // This matches the IR shape produced by stack allocation, allowing + // downstream VN/CSE to treat the surrounding code as if no allocator + // call were present. We rely on HasSideEffects's helper-properties + // model rather than the cached GTF_SIDE_EFFECT bit, which is + // conservative for calls. Any side effects in the call's arguments + // are preserved. + const bool dropRoot = op1->IsCall() && !op1->AsCall()->HasSideEffects(this); + + if (dropRoot) + { + GenTree* sideEffects = nullptr; + gtExtractSideEffList(op1, &sideEffects, GTF_SIDE_EFFECT, /* ignoreRoot */ true); + if (sideEffects != nullptr) + { + tree = sideEffects; + tree->SetMorphed(this, /* doChildren */ true); + } + else + { + tree->gtBashToNOP(); + } + } + else if ((op1->gtFlags & GTF_SIDE_EFFECT) != 0) { tree = gtUnusedValNode(op1); tree->SetMorphed(this, /* doChildren */ true);