Skip to content

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

Draft
max-charlamb wants to merge 1 commit intodotnet:mainfrom
max-charlamb:dev/max-charlamb/reuse-lto-handle
Draft

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

Conversation

@max-charlamb
Copy link
Copy Markdown
Member

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

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.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

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 join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants