Skip to content

Add skeleton interpreter backend to the jit compiler - #132139

Open
BrzVlad wants to merge 1 commit into
dotnet:mainfrom
BrzVlad:feature-clrinterp-ryujit
Open

Add skeleton interpreter backend to the jit compiler#132139
BrzVlad wants to merge 1 commit into
dotnet:mainfrom
BrzVlad:feature-clrinterp-ryujit

Conversation

@BrzVlad

@BrzVlad BrzVlad commented Aug 11, 2026

Copy link
Copy Markdown
Member

In interpreter enabled builds which have jit enabled, when DOTNET_InterpMode is set, it will result in first trying to compile a method via interpreter then fallback to jit if interpreter rejects it. This PR adds a new knob: DOTNET_InterpOptMethod which is set to a method name (it can also be *). When this flag is set, we first try to compile the method via jit, generating interpreter opcodes. Currently, the jit supports only a handful of opcodes and types. When not yet supported code is encountered, it calls Bailout to skip the method compilation. This method will then be compiled with interpreter library / jit.

InterpBackend::CompileMethod is the main entry point into the interpreter specific compilation bits. This gets called late in the jit compilation process, after optimizations have already run and after the HIR is rationalized into LIR, to make it easier to proces. This pass gets inserted before arch specific handling (like register allocation) and it will shortcircuit the rest of the compilation.

  • We first iterate over all local vars and allocate them to offsets (only support int32 for now). Given first vars are the method parameters, this should match the interpreter cconv.
  • Once we have offsets allocated to the local vars, we iterate over the gentrees. This pass will generate interpreter IR directly. We will likely have other passes over the code, to correctly implement actual offset allocation for temporaries (GenTree value return) and call args.
  • The core logic for generating interp IR from a GenTree is done in EmitGenTree. This receives the gen tree to process as well as the optional offset where to store the result. For example a GT_STORE_LCL_VAR node will emit the child gen tree asking to store the result directly into the local var. A GT_ADD node will ask emission of the childs to temporaries, so that it can use these offsets to add them together.
  • This version of the change adds handling for 2 opcodes: INTOP_ADD_I4_IMM and INTOP_BLT_I4_IMM, coupling multiple instructions into single super instruction
  • Branches record just a patch record, which are processed once all the code was generated.
  • Finally, BuildOutput produces the final code to publish. Greatly simplified version of the interpreter library code around InterpCompiler::FinalizeMethodData.

interpdump.cpp duplicates some logic from the interpreter library, so that we can have verbose dumping of the generated code.

Given the code generated via the jit is compatible with the code generated via the interpreter library, we could have an incremental approach to implementing full support. We can have a pipeline that runs full interpreter on the runtime tests with DOTNET_InterpOptMethod=*. The jit will try to compile all methods, bailout and skip for functionality it doesn't yet support. Support can be added until we never bailout out of the jit.

This skeleton is able to correctly compile the method, generating code that is almost 4x faster:

public static int InterpretedBenchmark(int arg)
{
    int numIterations = 1000000000;
    int local_var = arg;
    for (int i = 0; i < numIterations; i++)
         local_var += i;

    return local_var;
}

Interpreter library code:

IR_0000: initlocals     [nil <- nil], 16,32
IR_0003: safepoint      [nil <- nil],
IR_0004: ldc.i4         [48 <- nil], 1000000000
IR_0007: mov.4          [16 <- 48],
IR_000a: mov.4          [48 <- 0],
IR_000d: mov.4          [24 <- 48],
IR_0010: ldc.i4         [48 <- nil], 0
IR_0013: mov.4          [32 <- 48],
IR_0016: br             [nil <- nil], IR_0032
IR_0018: mov.4          [48 <- 24],
IR_001b: mov.4          [56 <- 32],
IR_001e: add.i4         [48 <- 48 56],
IR_0022: mov.4          [24 <- 48],
IR_0025: mov.4          [48 <- 32],
IR_0028: ldc.i4         [56 <- nil], 1
IR_002b: add.i4         [48 <- 48 56],
IR_002f: mov.4          [32 <- 48],
IR_0032: mov.4          [48 <- 32],
IR_0035: mov.4          [56 <- 16],
IR_0038: safepoint      [nil <- nil],
IR_0039: blt.i4         [nil <- 48 56], IR_0018
IR_003d: mov.4          [48 <- 24],
IR_0040: ret            [nil <- 48],

Jit-Interpreter code:

IR_0000: safepoint      [nil <- nil],
IR_0001: ldc.i4         [8 <- nil], 1000000000
IR_0004: mov.4          [16 <- 0],
IR_0007: ldc.i4.0       [24 <- nil],
IR_0009: add.i4         [16 <- 16 24],
IR_000d: add.i4.imm     [24 <- 24], 1
IR_0011: blt.i4.imm     [nil <- 24], 1000000000 IR_0009
IR_0015: ret            [nil <- 16],

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 6 pipeline(s).
10 pipeline(s) were filtered out due to trigger conditions.
There may be pipelines that require an authorized user to comment /azp run to run.

@dotnet-policy-service

Copy link
Copy Markdown
Contributor

Tagging subscribers to this area: @JulieLeeMSFT, @BrzVlad, @janvorli
See info in area-owners.md if you want to be subscribed.

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

Adds an initial JIT “interpreter IR” backend behind a new debug-only method filter knob, plus plumbing in the VM/interpreter to execute a new super-instruction and to publish JIT-produced interpreter bytecode as a method entrypoint.

Changes:

  • Adds a new JIT backend (InterpBackend) that translates rationalized LIR into interpreter bytecode, plus a debug-only bytecode disassembler (interpdump).
  • Introduces CORJIT_FLAG_INTERP and a new debug-only config knob (DOTNET_InterpOptMethod) to route selected methods through the JIT-to-interpreter-IR pipeline with fallback.
  • Adds INTOP_BLT_I4_IMM to the interpreter opcode set and execution engine, and factors safepoint handling into a shared slow-path helper.

Reviewed changes

Copilot reviewed 16 out of 16 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
src/coreclr/vm/jitinterface.cpp Adds shared interpreter-bytecode publishing helper and a new JIT→interp-IR compilation attempt path.
src/coreclr/vm/interpexec.cpp Factors safepoint slow-path logic and adds execution support for INTOP_BLT_I4_IMM.
src/coreclr/vm/eeconfig.h Adds debug-only IsInterpOptMethod query API.
src/coreclr/vm/eeconfig.cpp Parses/destroys the debug-only InterpOptMethod method list.
src/coreclr/jit/jitee.h Wires CORJIT_FLAG_INTERP into JIT flag mapping.
src/coreclr/jit/interpdump.h Declares debug-only interpreter bytecode disassembler.
src/coreclr/jit/interpdump.cpp Implements disassembly tables and printing for JIT-produced interpreter bytecode.
src/coreclr/jit/interpbackend.h Declares InterpBackend compilation state and entrypoint.
src/coreclr/jit/interpbackend.cpp Implements LIR→interpreter-bytecode emission, branch patching, and output packaging.
src/coreclr/jit/compiler.cpp Hooks interpreter-backend invocation after rationalization/GS phase when JIT_FLAG_INTERP is set.
src/coreclr/jit/CMakeLists.txt Adds new JIT sources/headers to the build.
src/coreclr/interpreter/inc/intops.def Adds new branch super-instruction opcode INTOP_BLT_I4_IMM.
src/coreclr/interpreter/inc/interpretershared.h Centralizes method/bytecode runtime layout via new shared header include.
src/coreclr/interpreter/inc/interpmethod.h New shared definition of InterpMethod and InterpByteCodeStart (and stack slot constants).
src/coreclr/inc/corjitflags.h Adds CORJIT_FLAG_INTERP to the VM↔JIT flag contract.
src/coreclr/inc/clrconfigvalues.h Adds internal config string InterpOptMethod.

Comment thread src/coreclr/vm/interpexec.cpp Outdated
Comment on lines +2166 to +2170
INTOP_CASE(INTOP_BLT_I4_IMM):
if (g_TrapReturningThreads)
{
InterpSafepointSlowPath();
}
Comment on lines +14053 to 14057
else if (res != CORJIT_SKIPPED)
{
pPrecode = Precode::AllocateInterpreterPrecode(ret, ftn->GetLoaderAllocator(), &amt);
// A genuine compilation failure (not a bailout) is a hard error.
COMPlusThrow(kInvalidProgramException);
}
Comment on lines 137 to +140
OPDEF(INTOP_BLT_UN_R8, "blt.un.r8", 4, 0, 2, InterpOpBranch)

OPDEF(INTOP_BLT_I4_IMM, "blt.i4.imm", 4, 0, 1, InterpOpBranch)

Comment on lines +14022 to +14029
// Call the JIT compiler directly, bypassing UnsafeJitFunctionWorker /
// invokeCompileMethod: WriteCode publishes the interpreter code through the
// nibble map, which requires GC info that this backend does not emit yet.
//
// The compiler itself lives in the native EE JIT manager (jitMgr->GetCompiler()),
// but the code we produce is interpreter bytecode, so we drive it through a
// CInterpreterJitInfo backed by the interpreter code manager.
CInterpreterJitInfo jitInfo{ config, ftn, ILHeader, interpreterMgr };
CORJIT_FLAG_SOFTFP_ABI = 30, // Enable armel calling convention
#endif
CORJIT_FLAG_USE_DISPATCH_HELPERS = 31, // The JIT should use helpers for interface dispatch instead of virtual stub dispatch
CORJIT_FLAG_INTERP = 32, // The JIT should generate CoreCLR interpreter IR instead of native machine code

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.

Should the interpreter target be treated as a new JIT target architecture (ie new JIT binary)?

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.

I think JIT interp should be built as R2R + JIT interp + Current interp with the last as fallback that could potentially be removed when JIT interp handles everything. I don't see a point in trying to have both JIT native and JIT interp here.

@BrzVlad

BrzVlad commented Aug 11, 2026

Copy link
Copy Markdown
Member Author

cc @dotnet/jit-contrib

In interpreter enabled builds which have jit enabled, when `DOTNET_InterpMode` is set, it will result in first trying to compile a method via interpreter then fallback to jit if interpreter rejects it. This PR adds a new knob: `DOTNET_InterpOptMethod` which is set to a method name (it can also be `*`). When this flag is set, we first try to compile the method via jit, generating interpreter opcodes. Currently, the jit supports only a handful of opcodes and types. When not yet supported code is encountered, it calls `Bailout` to skip the method compilation. This method will then be compiled with interpreter library / jit.

InterpBackend::CompileMethod is the main entry point into the interpreter specific compilation bits. This gets called late in the jit compilation process, after optimizations have already run and after the HIR is rationalized into LIR, to make it easier to proces. This pass gets inserted before arch specific handling (like register allocation) and it will shortcircuit the rest of the compilation. - We first iterate over all local vars and allocate them to offsets (only support int32 for now). Given first vars are the method parameters, this should match the interpreter cconv.
- Once we have offsets allocated to the local vars, we iterate over the gentrees. This pass will generate interpreter IR directly. We will likely have other passes over the code, to correctly implement actual offset allocation for temporaries (GenTree value return) and call args.
- The core logic for generating interp IR from a GenTree is done in `EmitGenTree`. This receives the gen tree to process as well as the optional offset where to store the result. For example a `GT_STORE_LCL_VAR` node will emit the child gen tree asking to store the result directly into the local var. A `GT_ADD` node will ask emission of the childs to temporaries, so that it can use these offsets to add them together.
- This version of the change adds handling for 2 opcodes: `INTOP_ADD_I4_IMM` and `INTOP_BLT_I4_IMM`, coupling multiple instructions into single super instruction
- Branches record just a patch record, which are processed once all the code was generated.
- Finally, BuildOutput produces the final code to publish. Greatly simplified version of the interpreter library code around `InterpCompiler::FinalizeMethodData`.

interpdump.cpp duplicates some logic from the interpreter library, so that we can have verbose dumping of the generated code.

Given the code generated via the jit is compatible with the code generated via the interpreter library, we could have an incremental approach to implementing full support. We can have a pipeline that runs full interpreter on the runtime tests with `DOTNET_InterpMode=*`. The jit will try to compile all methods, bailout and skip for functionality it doesn't yet support. Support is added until we never bailout out of the jit.

This skeleton is able to correctly compile the method, generating code that is almost 4x faster:
```
public static int InterpretedBenchmark(int arg)
{
    int numIterations = 1000000000;
    int local_var = arg;
    for (int i = 0; i < numIterations; i++)
         local_var += i;

    return local_var;
}
```

Interpreter library code:
```
IR_0000: initlocals     [nil <- nil], 16,32
IR_0003: safepoint      [nil <- nil],
IR_0004: ldc.i4         [48 <- nil], 1000000000
IR_0007: mov.4          [16 <- 48],
IR_000a: mov.4          [48 <- 0],
IR_000d: mov.4          [24 <- 48],
IR_0010: ldc.i4         [48 <- nil], 0
IR_0013: mov.4          [32 <- 48],
IR_0016: br             [nil <- nil], IR_0032
IR_0018: mov.4          [48 <- 24],
IR_001b: mov.4          [56 <- 32],
IR_001e: add.i4         [48 <- 48 56],
IR_0022: mov.4          [24 <- 48],
IR_0025: mov.4          [48 <- 32],
IR_0028: ldc.i4         [56 <- nil], 1
IR_002b: add.i4         [48 <- 48 56],
IR_002f: mov.4          [32 <- 48],
IR_0032: mov.4          [48 <- 32],
IR_0035: mov.4          [56 <- 16],
IR_0038: safepoint      [nil <- nil],
IR_0039: blt.i4         [nil <- 48 56], IR_0018
IR_003d: mov.4          [48 <- 24],
IR_0040: ret            [nil <- 48],
```

Jit-Interpreter code:
```
IR_0000: safepoint      [nil <- nil],
IR_0001: ldc.i4         [8 <- nil], 1000000000
IR_0004: mov.4          [16 <- 0],
IR_0007: ldc.i4.0       [24 <- nil],
IR_0009: add.i4         [16 <- 16 24],
IR_000d: add.i4.imm     [24 <- 24], 1
IR_0011: blt.i4.imm     [nil <- 24], 1000000000 IR_0009
IR_0015: ret            [nil <- 16],
```
Copilot AI review requested due to automatic review settings August 11, 2026 16:24
@BrzVlad
BrzVlad force-pushed the feature-clrinterp-ryujit branch from 40cea18 to 936541e Compare August 11, 2026 16:24

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

Copilot reviewed 16 out of 16 changed files in this pull request and generated no new comments.

Suppressed comments (6)

src/coreclr/jit/interpbackend.cpp:482

  • InterpBackend uses printf for tracing, which is discouraged in the JIT codebase (prefer JITDUMP so output is gated by JIT dump/verbosity knobs). This printf also runs for every compiled method in DEBUG builds, which can be extremely noisy.
#ifdef DEBUG
    printf("JIT->InterpIR: Generating interpreter IR for method %s\n", m_compiler->info.compFullName);
#endif

src/coreclr/interpreter/inc/intops.def:140

  • INTOP_BLT_I4_IMM is a conditional branch opcode but InterpOpIsCondBranch currently only recognizes the contiguous range INTOP_BRFALSE_I4..INTOP_BLT_UN_R8 (see src/coreclr/interpreter/intops.h:83-86). This means debugger walking and other opcode-classification logic will treat INTOP_BLT_I4_IMM as a non-branch.
OPDEF(INTOP_BLT_I4_IMM, "blt.i4.imm", 4, 0, 1, InterpOpBranch)

src/coreclr/vm/jitinterface.cpp:14052

  • In the JIT->interpreter-IR fast path, the returned sizeOfCode is never propagated to pSizeOfCode. This leaves the caller without the correct code size (e.g., PerfMap logging in prestub uses sizeOfCode for interpreter methods).
            if (SUCCEEDED(res) && nativeEntry != NULL)
            {
                ret = PublishInterpreterMethodCode(ftn, (TADDR)nativeEntry, &jitInfo,
                                                   &sizeOfILCode, isInterpreterCode, isTier0);
            }

src/coreclr/vm/interpexec.cpp:2170

  • INTOP_BLT_I4_IMM executes a safepoint slow path but doesn't update pFrame->ip first. INTOP_SAFEPOINT sets pFrame->ip = ip before entering the slow path; doing the same here keeps stack-walking / abort handling consistent.
                INTOP_CASE(INTOP_BLT_I4_IMM)
                    if (g_TrapReturningThreads)
                    {
                        InterpSafepointSlowPath();
                    }

src/coreclr/inc/corjitflags.h:68

  • Adding a new JIT/EE interface flag (CORJIT_FLAG_INTERP) may require bumping JITEEVersionIdentifier (src/coreclr/inc/jiteeversionguid.h) depending on compatibility requirements. Please confirm whether interface versioning needs to be updated for this change.
        CORJIT_FLAG_USE_DISPATCH_HELPERS    = 31, // The JIT should use helpers for interface dispatch instead of virtual stub dispatch
        CORJIT_FLAG_INTERP                  = 32, // The JIT should generate CoreCLR interpreter IR instead of native machine code
    };

src/coreclr/jit/interpbackend.cpp:472

  • Prefer JITDUMP over printf for JIT diagnostics, so output is controlled by existing verbosity switches and doesn't pollute stdout in debug builds.

This issue also appears on line 480 of the same file.

#ifdef DEBUG
    printf("JIT->InterpIR: Generated %d bytecode slots (%d bytes), allocaSize=%d, argsSize=%d, totalSize=%u\n",
           codeSlots, codeSizeBytes, (int32_t)allocaSize, argsSize, totalSize);
#endif

@adamperlin

Copy link
Copy Markdown
Contributor

This is really cool! This is kind of an open question, but I am wondering if it is possible at all to slot the interpreter in as a more traditional backend, at least for the codegen and emit pieces (with a codegeninterp.cpp, emitinterp.cpp) etc. and what the tradeoffs would be. I'm curious if you have any thoughts from a design standpoint?

@hez2010

hez2010 commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

This is kind of an open question, but I am wondering if it is possible at all to slot the interpreter in as a more traditional backend, at least for the codegen and emit pieces (with a codegeninterp.cpp, emitinterp.cpp) etc. and what the tradeoffs would be. I'm curious if you have any thoughts from a design standpoint?

I think from lowering the JIT starts encoding assumptions specific to native code generation like ABI argument placement, containment, target-specific decomposition, etc. So, if we end up with something like codegeninterp.cpp it would either have to reverse some of those transformations if we want to reuse the existing interpreter IR, or we would need to introduce the interpreter as a completely new platform target with its own lowering/codegen path.

The latter seems cleaner to me, but I’m not sure how much work that approach would take. I guess it would be more involved than just transforming RyuJIT LIR into the interpreter IR after Rationalization, but I could be wrong. For example, if it turns out that the RyuJIT IR no longer carries all the high-level information the interpreter needs, then the existing approach would run into problems, and we would either need rework the interpreter IR (which might end up being even more expensive than introducing a new target) or somehow reconstruct the information lost by earlier JIT transformations (is this even possible?).

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.

6 participants