Skip to content

Reuse a per-thread last-thrown-object handle - #127890

Closed
max-charlamb wants to merge 1 commit into
dotnet:mainfrom
max-charlamb:dev/max-charlamb/reuse-lto-handle
Closed

Reuse a per-thread last-thrown-object handle#127890
max-charlamb wants to merge 1 commit into
dotnet:mainfrom
max-charlamb:dev/max-charlamb/reuse-lto-handle

Conversation

@max-charlamb

@max-charlamb max-charlamb commented May 6, 2026

Copy link
Copy Markdown
Member

Note

This PR description was AI-generated with GitHub Copilot CLI.

Summary

Each Thread previously allocated and destroyed an OBJECTHANDLE for its last-thrown-object on every Thread::SetLastThrownObject transition. This is hot during exception throw/unwind (and especially nested throws). This PR allocates one strong handle per thread once at construction and updates the referent in-place via StoreObjectInHandle for the lifetime of the thread.

This is the same pattern NativeAOT uses for its per-thread last-thrown-object slot.

Behavior change

  • Thread::SetLastThrownObject collapses to a single StoreObjectInHandle call and becomes NOTHROW.
  • Thread::SafeSetLastThrownObject and Thread::SetLastThrownObjectHandle are removed (no longer needed with a permanent handle).
  • Thread::SafeUpdateLastThrownObject is renamed to Thread::UpdateLastThrownObject and simplified — the per-thread handle cannot fail to allocate, so the OOM fallback is dead code.
  • Thread::SetSOForLastThrownObject clobbers m_LastThrownObjectHandle to point at the global preallocated SO handle (g_pPreallocatedStackOverflowException), preserving the cheap pointer-compare in IsLastThrownObjectStackOverflowException. Stack overflow is process-fatal, so the leaked per-thread handle is inconsequential; ~Thread guards against destroying the global singleton.
  • IsLastThrownObjectNull uses ObjectHandleIsNull instead of comparing the handle pointer to NULL — the handle is now always allocated, so the old m_LastThrownObjectHandle == NULL test would always be false. ObjectHandleIsNull is MODE_ANY-safe (no VALIDATEOBJECTREF).

Audit / migration

Every site that compared the LTO OBJECTHANDLE directly was migrated:

  • Debugger::IsAtSafePlace uses Thread::IsLastThrownObjectStackOverflowException() (handle pointer compare against the global SO handle) instead of calling through EEDbgInterface.
  • The EX_CATCH paths in DoPrestub, DoInterpreterMethodPrestub, and GetInterpThreadContextWithPossiblyMissingThreadOrCallStub no longer read LastThrownObjectHandle() for a now-trivial non-NULL assert; they assert via IsLastThrownObjectNull() and consume the throwable through LastThrownObject().
  • DAC paths in ClrDataTask::GetLastExceptionThrown and ClrDataAccess::GetThreadData are guarded by IsLastThrownObjectNull() before reading the handle, since the handle is now always non-NULL but may have a NULL payload.
  • EEDbgInterfaceImpl::GetThreadException LTO fallback is guarded by IsLastThrownObjectNull() to preserve the NULL-handle-means-no-exception contract for debugger IPC callers.
  • EEDbgInterface::IsThreadExceptionNull is removed; callers use Thread::IsThrowableNull() directly.

Validation

  • Windows x64 Checked build clean.
  • Exceptions/Exceptions (incl. ForeignThreadExceptions): exit 100.
  • baseservices/exceptions: exit 100 (only the two intentional process-death testers fail by design).
  • tools.cdactests: pass.
  • JIT/Methodical/eh: 164/164 real tests pass.

CI will exercise broader matrices.

Copilot AI review requested due to automatic review settings May 6, 2026 22:49
@dotnet-policy-service

Copy link
Copy Markdown
Contributor

Tagging subscribers to this area: @agocke
See info in area-owners.md if you want to be subscribed.

@max-charlamb

This comment was marked as outdated.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This PR optimizes CoreCLR exception hot paths by reusing a per-thread strong GC handle for Thread’s last-thrown-object (LTO) instead of allocating/destroying a handle on each Thread::SetLastThrownObject transition. It also updates debugger/DAC call sites to account for the LTO handle always being allocated and reduces the EE↔debugger interface surface.

Changes:

  • Allocate a per-thread LTO strong handle once in Thread::Thread() and update its payload via StoreObjectInHandle in Thread::SetLastThrownObject() (now NOTHROW).
  • Migrate debugger, prestub/interpreter, and DAC code to use payload-based LTO checks instead of handle-pointer comparisons / NULL-handle checks.
  • Remove EEDbgInterface::GetThreadException / IsThreadExceptionNull and route debugger event exception handle selection through Thread.

Reviewed changes

Copilot reviewed 13 out of 13 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
src/coreclr/vm/threads.h Adds debugger-event exception handle helper; changes LTO readers to inspect handle payload rather than handle pointer.
src/coreclr/vm/threads.cpp Allocates/destroys per-thread LTO strong handle; simplifies SetLastThrownObject to a payload write.
src/coreclr/vm/prestub.cpp Updates prestub EX_CATCH paths to consume LastThrownObject() (payload) instead of handle pointer checks.
src/coreclr/vm/jitinterface.cpp Removes SafeSetLastThrownObject usage in exception handling path.
src/coreclr/vm/exceptionhandling.cpp Replaces SafeSetLastThrownObject(NULL) with SetLastThrownObject(NULL).
src/coreclr/vm/excep.cpp Removes SafeSetLastThrownObject usage; adjusts OOM/thread-abort bucketing comments.
src/coreclr/vm/eedbginterfaceimpl.h Removes debugger interface methods for fetching/checking thread exception handle.
src/coreclr/vm/eedbginterfaceimpl.cpp Removes implementations of GetThreadException / IsThreadExceptionNull.
src/coreclr/vm/eedbginterface.h Removes GetThreadException / IsThreadExceptionNull from the EE debug interface.
src/coreclr/vm/clrex.cpp Updates SO path to set LTO via SetLastThrownObject(preallocated SO).
src/coreclr/debug/ee/debugger.cpp Uses new thread helper to pick exception handle for debugger IPC; updates null checks and SO detection.
src/coreclr/debug/daccess/task.cpp Switches DAC last-exception logic to payload-based LTO null check.
src/coreclr/debug/daccess/request.cpp Switches DAC thread-data exception handle selection to payload-based LTO null check.

Comment thread src/coreclr/vm/threads.h Outdated
Comment thread src/coreclr/vm/threads.h Outdated
Comment thread src/coreclr/vm/threads.cpp Outdated
Allocate a single strong GC handle per Thread at construction for the
LastThrownObject slot and reuse it for the thread's lifetime, eliminating
per-throw handle create/destroy churn. The handle payload is updated in
place via StoreObjectInHandle.

- Remove SetLastThrownObjectHandle, SafeSetLastThrownObject, and
  SafeUpdateLastThrownObject (renamed to UpdateLastThrownObject).
- Add SetSOForLastThrownObject to clobber the LTO handle with the global
  preallocated SO handle for cheap SO detection via pointer compare.
- Guard ~Thread to skip DestroyStrongHandle when LTO points at the
  global SO singleton.
- Use ObjectHandleIsNull for MODE_ANY-safe null checks in
  IsLastThrownObjectNull.
- Remove IsThreadExceptionNull from EEDbgInterface; callers use
  Thread::IsThrowableNull directly.
- Simplify handle lifetime in excep.cpp, jitinterface.cpp, prestub.cpp,
  exceptionhandling.cpp, and DAC request/task paths.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@max-charlamb
max-charlamb force-pushed the dev/max-charlamb/reuse-lto-handle branch from 931da5c to ab69225 Compare May 6, 2026 23:49
@max-charlamb

Copy link
Copy Markdown
Member Author

@EgorBot -intel -amd

using BenchmarkDotNet.Attributes;
using System.Runtime.CompilerServices;

[MemoryDiagnoser]
public class ExceptionBench
{
    [Benchmark]
    public int SimpleThrowCatch()
    {
        try { throw new InvalidOperationException("x"); }
        catch (Exception e) { return e.Message.Length; }
    }

    [Benchmark]
    public int Rethrow()
    {
        try
        {
            try { throw new InvalidOperationException("x"); }
            catch { throw; }
        }
        catch (Exception e) { return e.Message.Length; }
    }

    [Benchmark]
    public int CatchAndThrowNew()
    {
        try
        {
            try { throw new InvalidOperationException("inner"); }
            catch { throw new ApplicationException("outer"); }
        }
        catch (Exception e) { return e.Message.Length; }
    }

    [Benchmark]
    public int NestedThrowCatch()
    {
        int r = 0;
        try { Outer(); }
        catch (Exception e) { r = e.Message.Length; }
        return r;
    }

    [MethodImpl(MethodImplOptions.NoInlining)]
    static void Outer()
    {
        try { Inner(); }
        catch (InvalidOperationException) { throw new ApplicationException("rewrapped"); }
    }

    [MethodImpl(MethodImplOptions.NoInlining)]
    static void Inner() => throw new InvalidOperationException("x");

    [Benchmark]
    public int DeepThrowCatch()
    {
        try { Recurse(20); return 0; }
        catch (Exception e) { return e.Message.Length; }
    }

    [MethodImpl(MethodImplOptions.NoInlining)]
    static void Recurse(int n)
    {
        if (n == 0) throw new InvalidOperationException("deep");
        Recurse(n - 1);
    }
}

Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants