Add skeleton interpreter backend to the jit compiler - #132139
Conversation
|
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. |
|
Tagging subscribers to this area: @JulieLeeMSFT, @BrzVlad, @janvorli |
There was a problem hiding this comment.
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_INTERPand 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_IMMto 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. |
| INTOP_CASE(INTOP_BLT_I4_IMM): | ||
| if (g_TrapReturningThreads) | ||
| { | ||
| InterpSafepointSlowPath(); | ||
| } |
| 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); | ||
| } |
| OPDEF(INTOP_BLT_UN_R8, "blt.un.r8", 4, 0, 2, InterpOpBranch) | ||
|
|
||
| OPDEF(INTOP_BLT_I4_IMM, "blt.i4.imm", 4, 0, 1, InterpOpBranch) | ||
|
|
| // 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 |
There was a problem hiding this comment.
Should the interpreter target be treated as a new JIT target architecture (ie new JIT binary)?
There was a problem hiding this comment.
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.
|
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],
```
40cea18 to
936541e
Compare
There was a problem hiding this comment.
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
InterpBackendusesprintffor tracing, which is discouraged in the JIT codebase (preferJITDUMPso output is gated by JIT dump/verbosity knobs). Thisprintfalso 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_IMMis a conditional branch opcode butInterpOpIsCondBranchcurrently only recognizes the contiguous rangeINTOP_BRFALSE_I4..INTOP_BLT_UN_R8(see src/coreclr/interpreter/intops.h:83-86). This means debugger walking and other opcode-classification logic will treatINTOP_BLT_I4_IMMas 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
sizeOfCodeis never propagated topSizeOfCode. This leaves the caller without the correct code size (e.g., PerfMap logging in prestub usessizeOfCodefor 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_IMMexecutes a safepoint slow path but doesn't updatepFrame->ipfirst.INTOP_SAFEPOINTsetspFrame->ip = ipbefore 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 bumpingJITEEVersionIdentifier(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
JITDUMPoverprintffor 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
|
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 |
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?). |
In interpreter enabled builds which have jit enabled, when
DOTNET_InterpModeis 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_InterpOptMethodwhich 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 callsBailoutto skip the method compilation. This method will then be compiled with interpreter library / jit.InterpBackend::CompileMethodis 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.EmitGenTree. This receives the gen tree to process as well as the optional offset where to store the result. For example aGT_STORE_LCL_VARnode will emit the child gen tree asking to store the result directly into the local var. AGT_ADDnode will ask emission of the childs to temporaries, so that it can use these offsets to add them together.INTOP_ADD_I4_IMMandINTOP_BLT_I4_IMM, coupling multiple instructions into single super instructionInterpCompiler::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:
Interpreter library code:
Jit-Interpreter code: