Repository navigation
[JSC] A catch OSR entrypoint needs a sample of every operand #772
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
ede86ed
6b15c0c
7a52d8f
d1b9040
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,35 @@ | ||
| //@ runFTLNoCJIT; run("ftl-no-cjit-eager-catch-liveness", "--useFTLJIT=true", "--useConcurrentJIT=false", "--useLazyCatchLiveness=false") | ||
|
|
||
| // This handler has to get a catch OSR entrypoint: at some point it runs in optimized code. | ||
|
|
||
| function thrower(shouldThrow) { | ||
| if (shouldThrow) | ||
| throw new Error("thrown"); | ||
| } | ||
| noInline(thrower); | ||
|
|
||
| function shouldEnter(f) { | ||
| for (let i = 0; i < 10 * testLoopCount; ++i) { | ||
| if (f(!(i % 10))) | ||
| return; | ||
| } | ||
| if ($vm.useDFGJIT()) | ||
| throw new Error(f.name + ": the handler never ran in optimized code"); | ||
| } | ||
|
|
||
| // The completion value of the finally is live at the catch and always the empty value there. | ||
| function catchInsideFinally(shouldThrow) { | ||
| let optimized = false; | ||
| try { | ||
| try { | ||
| thrower(shouldThrow); | ||
| } catch { | ||
| optimized = $vm.dfgTrue(); | ||
| } | ||
| } finally { | ||
| thrower(false); | ||
| } | ||
| return optimized; | ||
| } | ||
| noInline(catchInsideFinally); | ||
| shouldEnter(catchInsideFinally); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,98 @@ | ||
| //@ defaultRun; run("eager-catch-liveness", "--useLazyCatchLiveness=false") | ||
|
|
||
| // A catch OSR entrypoint must not give the DFG an operand without a prediction. The DFG takes SpecNone for code that | ||
| // never ran: a double use of such a value is DoubleRep(NotCellNorBigIntUse), which is ToNumber, not a check. | ||
|
|
||
| function shouldBe(actual, expected, name) { | ||
| if (actual !== expected) | ||
| throw new Error(name + ": expected " + expected + " but got " + actual); | ||
| } | ||
|
|
||
| function thrower(shouldThrow) { | ||
| if (shouldThrow) | ||
| throw new Error("thrown"); | ||
| } | ||
| noInline(thrower); | ||
|
|
||
| const notNumbers = [null, true, undefined, false]; | ||
|
|
||
| // With Options::useLazyCatchLiveness() these catches run before their value-profile buffer exists and not again | ||
| // until the DFG has compiled the function, so the buffer has no sample at all. | ||
| const neverSampled = { | ||
| localAfterCatch(value, shouldThrow) { | ||
| const t = value.p; | ||
| try { | ||
| thrower(shouldThrow); | ||
| } catch { } | ||
| return String(t); | ||
| }, | ||
|
|
||
| localInFinally(value, shouldThrow) { | ||
| const t = value.p; | ||
| let result; | ||
| try { | ||
| try { | ||
| thrower(shouldThrow); | ||
| } finally { | ||
| result = String(t); | ||
| } | ||
| } catch { } | ||
| return result; | ||
| }, | ||
|
|
||
| localInNewArrayInCatch(value, shouldThrow) { | ||
| const t = value.p; | ||
| try { | ||
| thrower(shouldThrow); | ||
| } catch { | ||
| return String([t][0]); | ||
| } | ||
| return t; | ||
| }, | ||
|
|
||
| argumentInNewArrayInCatch(value, shouldThrow, t) { | ||
| try { | ||
| thrower(shouldThrow); | ||
| } catch { | ||
| return String([t][0]); | ||
| } | ||
| return t; | ||
| }, | ||
| }; | ||
|
|
||
| for (const name in neverSampled) { | ||
| const f = neverSampled[name]; | ||
| noInline(f); | ||
| f({ p: 0.5 }, true, 0.5); | ||
| for (let i = 0; i < testLoopCount; ++i) | ||
| f({ p: i + 0.5 }, false, i + 0.5); | ||
| for (const value of notNumbers) | ||
| shouldBe(f({ p: value }, true, value), String(value), name); | ||
| } | ||
|
|
||
| // With or without the option: the last sample before each update of the prediction has t in its TDZ, the empty value. | ||
| function sampledWhileEmpty(skipDeclaration, value, shouldThrow, array) { | ||
| switch (skipDeclaration) { | ||
| case 0: | ||
| let t = value.p; | ||
| case 1: | ||
| try { | ||
| thrower(shouldThrow); | ||
| } catch { | ||
| if (!skipDeclaration) { | ||
| array[0] = t; | ||
| return String(array[0]); | ||
| } | ||
| } | ||
| } | ||
| } | ||
| noInline(sampledWhileEmpty); | ||
|
|
||
| sampledWhileEmpty(0, { p: 0.5 }, true, [0.5]); | ||
| for (let i = 0; i < testLoopCount; ++i) { | ||
| sampledWhileEmpty(0, { p: i + 0.5 }, false, [0.5]); | ||
| if (!(i % 20)) | ||
| sampledWhileEmpty(1, { p: 0.5 }, true, [0.5]); | ||
| } | ||
| for (const value of notNumbers) | ||
| shouldBe(sampledWhileEmpty(0, { p: value }, true, [0.5]), String(value), "sampledWhileEmpty"); |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -3254,7 +3254,7 @@ JSC_DEFINE_JIT_OPERATION(operationTryOSREnterAtCatchAndValueProfile, UGPRPair, ( | |||||||||||||||||||||||||||||||||||||
| if (!buffer) | ||||||||||||||||||||||||||||||||||||||
| OPERATION_RETURN(scope, encodeResult(nullptr, nullptr)); | ||||||||||||||||||||||||||||||||||||||
| buffer->forEach([&] (ValueProfileAndVirtualRegister& profile) { | ||||||||||||||||||||||||||||||||||||||
| profile.m_buckets[0] = JSValue::encode(callFrame->uncheckedR(profile.m_operand).jsValue()); | ||||||||||||||||||||||||||||||||||||||
| profile.sample(callFrame->uncheckedR(profile.m_operand).jsValue()); | ||||||||||||||||||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 (optional) Under default lazy catch liveness, a rarely firing catch never gets a DFG or FTL OSR entrypoint, so every caught exception OSR-exits to baseline permanently. The new bail at DFGByteCodeParser.cpp:10241 skips the entrypoint when the buffer has no sample. operationTryOSREnterAtCatchAndValueProfile returns at JITOperations.cpp:3246 before sampling whenever a replacement exists, so the buffer never gains a sample and the FTL plan bails too. Fix: when the replacement has no entrypoint for this catch, still create and sample the buffer so a later plan can make one. The base made an entrypoint with SpecNone predictions instead. Why this was flaggedTrigger: Options::useLazyCatchLiveness() is on (OptionsList.h:722 default true). A function's catch runs once in LLInt/Baseline before the first DFG threshold. operationOptimize creates the buffer and delays once (JITOperations.cpp:3137-3143). The catch does not fire in that window, so at parse time every operand's computeUpdatedPrediction() is SpecNone and the parser takes NEXT_OPCODE at DFGByteCodeParser.cpp:10237-10241: no catch entrypoint. From then on each caught exception OSR-exits to the baseline op_catch, which calls operationTryOSREnterAtCatchAndValueProfile; with a DFG/FTL replacement it returns at JITOperations.cpp:3240-3246 and never reaches the sample() loop at 3256-3258, so the buffer stays unsampled. The later FTL plan parses the same baseline block and bails identically. Exception exits never jettison (ExitKind.cpp:34-36; DFGOSRExitCompilerCommon.cpp:44-50), so nothing ever recompiles with an entrypoint; the handler and the rest of the call run in baseline on every exception. On the base branch the entrypoint was made (with SpecNone predictions, the bug this PR fixes). Verification: Triggered when Options::useLazyCatchLiveness() is on (default true, OptionsList.h:722) and a catch executes before the first DFG threshold but not again during the single warm-up delay; the catch then never becomes a DFG or FTL OSR entrypoint. DFGByteCodeParser.cpp:10237-10241: if any operand's computeUpdatedPrediction() is SpecNone the parser does NEXT_OPCODE(op_catch) and never sets isCatchEntrypoint.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Not in this PR. I built and measured exactly this: on a refused entry the operation samples, and the lazy option stops deferring the buffer once the block has optimized code. It does end the stuck state, but the effect on run time goes both ways. One loop of 1e9 iterations whose handler first runs after tier-up, release + LTO, median of 5 fresh processes, ms:
Without it the first exceptions are nearly free and each later one costs more. With it the loop settles at about twice the time of one that never throws: the FTL code that has the catch entrypoint runs that loop slower (18 ms against 11 ms for 5 million iterations). In another loop the entrypoint made no difference, and I do not know yet which loops it applies to. It also changes the state that a catch that first runs after tier-up has always been in, with the option on or off. So it gets its own PR. What leaving it out costs here is in the Performance section of the description. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🚀 Performance & Scalability | 🟡 Minor | ⚡ Quick win Keep profiling when the replacement has no catch entrypoint. If lazy catch liveness leaves the buffer unsampled at compile time, the parser now rejects the catch entrypoint until every operand has a prediction. (github.com) When a DFG/FTL replacement exists, this operation returns from the 🤖 Prompt for AI Agents
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Not in this PR. I built and measured exactly this: on a refused entry the operation samples, and the lazy option stops deferring the buffer once the block has optimized code. It does end the stuck state, but the effect on run time goes both ways. One loop of 1e9 iterations whose handler first runs after tier-up, release + LTO, median of 5 fresh processes, ms:
Without it the first exceptions are nearly free and each later one costs more. With it the loop settles at about twice the time of one that never throws: the FTL code that has the catch entrypoint runs that loop slower (18 ms against 11 ms for 5 million iterations). In another loop the entrypoint made no difference, and I do not know yet which loops it applies to. It also changes the state that a catch that first runs after tier-up has always been in, with the option on or off. So it gets its own PR. What leaving it out costs here is in the Performance section of the description. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Your measurements show a trade-off: collecting samples after refused catch OSR entry removes the profiling gap, but the resulting FTL catch entrypoint can slow normal loop execution. This also changes existing behavior for catches that first execute after tier-up. Keeping this change in a separate PR makes sense. No change is needed here for this finding. Would you like me to open a follow-up issue for the separate PR? ✏️ Learnings added
You are interacting with an AI system. |
||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| OPERATION_RETURN(scope, encodeResult(nullptr, nullptr)); | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
Uh oh!
There was an error while loading. Please reload this page.