Repository navigation
[JSC] The slow path of a run-time-length array allocation takes the butterfly from the size class of its inline path #767
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
base: main
Are you sure you want to change the base?
Changes from all commits
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,62 @@ | ||
| // Arrays with a run-time length of 0, 1 and 2, from a rest parameter and from new Array(length): never grown, grown by | ||
| // one push, grown by five pushes, and grown by one unshift. An array that optimized code allocates inline has room for | ||
| // its length and no more, so the first push or unshift after it pays for the growth. | ||
|
|
||
| function rest(...values) { | ||
| return values; | ||
| } | ||
| noInline(rest); | ||
|
|
||
| function restThenPush(...values) { | ||
| values.push(0); | ||
| return values; | ||
| } | ||
| noInline(restThenPush); | ||
|
|
||
| function restThenFivePushes(...values) { | ||
| for (let i = 0; i < 5; ++i) | ||
| values.push(i); | ||
| return values; | ||
| } | ||
| noInline(restThenFivePushes); | ||
|
|
||
| function restThenUnshift(first, ...values) { | ||
| values.unshift(first); | ||
| return values; | ||
| } | ||
| noInline(restThenUnshift); | ||
|
|
||
| function make(length) { | ||
| return new Array(length); | ||
| } | ||
| noInline(make); | ||
|
|
||
| function makeThenPush(length) { | ||
| const array = new Array(length); | ||
| array.push(0); | ||
| return array; | ||
| } | ||
| noInline(makeThenPush); | ||
|
|
||
| function makeThenFivePushes(length) { | ||
| const array = new Array(length); | ||
| for (let i = 0; i < 5; ++i) | ||
| array.push(i); | ||
| return array; | ||
| } | ||
| noInline(makeThenFivePushes); | ||
|
|
||
| const iterations = 30000; | ||
| let totalLength = 0; | ||
| for (let i = 0; i < iterations; ++i) { | ||
| totalLength += rest().length + rest(i).length + rest(i, i).length; | ||
| totalLength += restThenPush().length + restThenPush(i).length + restThenPush(i, i).length; | ||
| totalLength += restThenFivePushes().length + restThenFivePushes(i).length + restThenFivePushes(i, i).length; | ||
| totalLength += restThenUnshift(i).length + restThenUnshift(i, i).length + restThenUnshift(i, i, i).length; | ||
| totalLength += make(0).length + make(1).length + make(2).length; | ||
| totalLength += makeThenPush(0).length + makeThenPush(1).length + makeThenPush(2).length; | ||
| totalLength += makeThenFivePushes(0).length + makeThenFivePushes(1).length + makeThenFivePushes(2).length; | ||
| } | ||
|
|
||
| if (totalLength !== 60 * iterations) | ||
| throw new Error("bad total length: " + totalLength); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,115 @@ | ||
| //@ skip if not $jitTests | ||
| //@ runDefault("--useConcurrentJIT=false") | ||
| //@ runDefault("--useConcurrentJIT=false", "--useFTLJIT=false") | ||
| //@ runDefault("--useConcurrentJIT=false", "--forceGCSlowPaths=true") | ||
| //@ runDefault("--useConcurrentJIT=false", "--useFTLJIT=false", "--forceGCSlowPaths=true") | ||
|
|
||
| // DFG and FTL allocate the storage of an array inline. For a length that is known only at run time, the inline path | ||
| // asks for room for exactly that many elements. When it finds no free cell of that size class, it calls a slow path. | ||
| // The slow path must take the storage from the same size class: that is what refills the free list that the inline path | ||
| // reads. If it takes a larger cell (the capacity that the runtime gives a short array: 5 elements for length 0, 3 for | ||
| // length 1), that free list stays empty and each later allocation calls the slow path again. | ||
| // | ||
| // Arrays that one function makes one after the other have their storage in adjacent cells. So the most frequent | ||
| // distance between two consecutive storages is the cell size of the size class that they come from. The test reads | ||
| // that distance for the arrays that DFG code made and for the arrays that FTL code made. --forceGCSlowPaths=true sends | ||
| // each allocation to the slow path, so those modes read the size class of the slow path alone. | ||
|
|
||
| const interpreterOrBaseline = 0; | ||
| const dfg = 1; | ||
| const ftl = 2; | ||
| const tierNames = ["the interpreter or the Baseline JIT", "DFG", "FTL"]; | ||
| const topTier = $vm.useFTLJIT() ? ftl : dfg; | ||
|
|
||
| // The function under test sets this to the tier that ran it. | ||
| let tier = interpreterOrBaseline; | ||
|
|
||
| // The loop of a test ends when the top tier made this many arrays. | ||
| const wantedSamples = 1000; | ||
| // A tier below the top one is checked when it made at least this many. | ||
| const minimumSamples = 200; | ||
| const maximumCalls = 100 * testLoopCount; | ||
|
|
||
| // A default constructor passes its arguments on with a spread, and DFG calls the Array constructor for that. | ||
| class DerivedArray extends Array { | ||
| constructor(length) | ||
| { | ||
| super(length); | ||
| } | ||
| } | ||
| function identity(value) { return value; } | ||
|
|
||
| function mostFrequent(counts) { | ||
| let result; | ||
| let best = 0; | ||
| for (const [value, count] of counts) { | ||
| if (count > best) { | ||
| best = count; | ||
| result = value; | ||
| } | ||
| } | ||
| return result; | ||
| } | ||
|
|
||
| // Each test has a function of its own, so that each one goes through DFG and then FTL. The name in its source keeps | ||
| // two tests of one expression from sharing code. | ||
| function test(name, parameters, expression, args, expectedDistance) { | ||
| const make = new Function(parameters, "/* " + name + " */ tier = $vm.ftlTrue() ? ftl : ($vm.dfgTrue() ? dfg : interpreterOrBaseline); return " + expression + ";"); | ||
| noInline(make); | ||
|
|
||
| const distances = [null, new Map, new Map]; | ||
| const samples = [0, 0, 0]; | ||
| // Every array stays alive, so that no cell is used again while the test measures. | ||
| const arrays = [make(...args)]; | ||
| for (let i = 0; i < maximumCalls && samples[topTier] < wantedSamples; ++i) { | ||
| const array = make(...args); | ||
| if (tier !== interpreterOrBaseline) { | ||
| const distance = $vm.deltaBetweenButterflies(array, arrays[arrays.length - 1]); | ||
| distances[tier].set(distance, (distances[tier].get(distance) || 0) + 1); | ||
| samples[tier]++; | ||
| } | ||
| arrays.push(array); | ||
| } | ||
|
|
||
| if (samples[topTier] < wantedSamples) | ||
| throw new Error(name + ": only " + samples[topTier] + " arrays came from " + tierNames[topTier]); | ||
| for (let measured = dfg; measured <= topTier; ++measured) { | ||
| if (samples[measured] < minimumSamples) | ||
| continue; | ||
| const distance = mostFrequent(distances[measured]); | ||
| if (distance !== expectedDistance) | ||
| throw new Error(name + " in " + tierNames[measured] + ": expected " + expectedDistance + " but got " + distance); | ||
| } | ||
| } | ||
|
|
||
| if ($vm.useDFGJIT()) { | ||
| // Not literals: a literal shares its storage until a write, and an empty literal has no element type yet. | ||
| const noElements = [1, 2]; | ||
| noElements.length = 0; | ||
| const oneElement = [1, 2]; | ||
| oneElement.length = 1; | ||
| const twoElements = [1, 2, 3]; | ||
| twoElements.length = 2; | ||
|
|
||
| // A length of 0 or 1 that is known only at run time: 8 bytes of header and at most one element of 8 bytes. | ||
| test("new Array(0)", "length", "new Array(length)", [0], 16); | ||
| test("new Array(1)", "length", "new Array(length)", [1], 16); | ||
| test("Array(1)", "length", "Array(length)", [1], 16); | ||
| test("new DerivedArray(0)", "length", "new DerivedArray(length)", [0], 16); | ||
| test("rest parameter, no arguments", "...values", "values", [], 16); | ||
| test("rest parameter, one argument", "...values", "values", [1], 16); | ||
| test("rest parameter after a named one, one argument", "first, ...values", "values", [1, 2], 16); | ||
| test("slice of no elements", "array", "array.slice()", [noElements], 16); | ||
| test("slice of one element", "array", "array.slice()", [oneElement], 16); | ||
| test("map of one element", "array", "array.map(identity)", [oneElement], 16); | ||
| test("two spreads, one element", "first, second", "[...first, ...second]", [oneElement, noElements], 16); | ||
|
|
||
| // A run-time length of 2: the inline path and the runtime agree on 32 bytes. | ||
| test("new Array(2)", "length", "new Array(length)", [2], 32); | ||
| test("rest parameter, two arguments", "...values", "values", [1, 2], 32); | ||
| test("slice of two elements", "array", "array.slice()", [twoElements], 32); | ||
|
|
||
| // A length that the compiler knows: the inline path gives the capacity of the runtime, and so does its slow path. | ||
| test("[value]", "value", "[value]", [1], 32); | ||
| test("[]", "", "[]", [], 48); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2888,6 +2888,59 @@ JSC_DEFINE_JIT_OPERATION(operationNewArrayWithSize, char*, (JSGlobalObject* glob | |
| OPERATION_RETURN(scope, std::bit_cast<char*>(result)); | ||
| } | ||
|
|
||
| // An inline allocation of an array with a run-time length asks auxiliarySpace for sizeof(IndexingHeader) + | ||
| // length * sizeof(JSValue) bytes (SpeculativeJIT::emitAllocateButterfly, FTL allocateJSArray). This storage comes from | ||
| // that same size class, so that the slow path refills the free list that the inline path reads. JSArray::tryCreate | ||
| // gives a length of 0 or 1 the minimum capacity of the runtime instead, which is another size class: the inline path | ||
| // would then find its free list empty at each allocation. | ||
| static ALWAYS_INLINE JSArray* tryCreateArrayInSizeClassOfInlineAllocation(VM& vm, Structure* structure, unsigned length) | ||
| { | ||
| ASSERT(!hasAnyArrayStorage(structure->indexingType())); | ||
| if (length > MAX_STORAGE_VECTOR_LENGTH) [[unlikely]] | ||
| return nullptr; | ||
|
|
||
| unsigned vectorLength = Butterfly::availableContiguousVectorLength(structure, length); | ||
|
Comment on lines
+2896
to
+2902
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) Programs that grow a rest-parameter or new Array(n) array right after creating it now pay a reallocation on every call, where the base often gave spare room. tryCreateArrayInSizeClassOfInlineAllocation at DFGOperations.cpp:2902 sizes a run-time length of 0 or 1 to one element, and the inline path it now keeps fed stores vectorLength == length, so the first push or unshift goes through JSObject::ensureLengthSlow. Fix: keep the slow path in the inline size class but give a run-time length of 0 or 1 the runtime minimum capacity on the inline path as well (one compare and conditional move in emitAllocateButterfly and FTL allocateJSArray), or state in the PR that the growth cost is accepted for Bun workloads. Why this was flaggedA DFG or FTL compiled function with a rest parameter, new Array(n), Array(n), slice or map whose length is 0 or 1 at run time, followed by push or unshift. On the base branch the inline path in SpeculativeJIT::emitAllocateButterfly (DFGSpeculativeJIT.cpp:14315) finds its size-16 free list empty almost every time, so JSArray::tryCreate at DFGOperations.cpp:2882 served the array with vectorLength 5 (length 0) or 3 (length 1) via Butterfly::optimalContiguousVectorLength. After the change tryCreateArrayInSizeClassOfInlineAllocation (DFGOperations.cpp:2896-2910) uses Butterfly::availableContiguousVectorLength, giving vectorLength 1, and the refilled free list makes the inline path, which stores vectorLength == publicLength (DFGSpeculativeJIT.cpp:14330, FTLLowerDFGToB3.cpp:24295), serve the rest. Every later push or unshift beyond that one slot calls JSObject::ensureLengthSlow (JSObject.cpp:4119) and allocates a second butterfly. No guard limits this: the growth path is unchanged and nothing on the inline path applies BASE_CONTIGUOUS_VECTOR_LEN. Verification: New helper at DFGOperations.cpp:2902 sizes the butterfly with
Collaborator
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. This is the decision that the description leaves to a maintainer (Downsides, and "For a maintainer to decide" in the Notes). The table there has both states of The minimum capacity on the inline path is option 3 there. It keeps 5 and 3 elements, adds the compare and the conditional move to each run-time-length allocation of each length, and keeps 64 and 48 bytes for an array of length 0 and 1 where this change has 32. I did not build it. I can do that as a follow-up if a maintainer picks it. |
||
| Butterfly* butterfly = Butterfly::tryCreateUninitialized(vm, nullptr, 0, structure->outOfLineCapacity(), true, vectorLength * sizeof(EncodedJSValue)); | ||
| if (!butterfly) [[unlikely]] | ||
| return nullptr; | ||
| butterfly->setVectorLength(vectorLength); | ||
| butterfly->setPublicLength(length); | ||
| Butterfly::clearRange(structure->indexingType(), butterfly, 0, vectorLength); | ||
| return JSArray::createWithButterfly(vm, nullptr, structure, butterfly); | ||
| } | ||
|
|
||
| // The slow path of an inline allocation of an array with a run-time length. Code that makes no inline attempt calls | ||
| // operationNewArrayWithSize. | ||
| JSC_DEFINE_JIT_OPERATION(operationNewArrayWithSizeAfterInlineAllocation, char*, (JSGlobalObject* globalObject, Structure* arrayStructure, int32_t size, Butterfly* butterfly)) | ||
| { | ||
| VM& vm = globalObject->vm(); | ||
| CallFrame* callFrame = DECLARE_CALL_FRAME(vm); | ||
| JITOperationPrologueCallFrameTracer tracer(vm, callFrame); | ||
| auto scope = DECLARE_THROW_SCOPE(vm); | ||
|
|
||
| if (size < 0) [[unlikely]] { | ||
| throwException(globalObject, scope, createRangeError(globalObject, ArrayInvalidLengthError)); | ||
| OPERATION_RETURN(scope, nullptr); | ||
| } | ||
|
|
||
| JSArray* result; | ||
| if (butterfly) { | ||
| ASSERT(butterfly->publicLength() <= butterfly->vectorLength()); | ||
| result = JSArray::createWithButterfly(vm, nullptr, arrayStructure, butterfly); | ||
| } else { | ||
| // A length that is too large for contiguous storage comes with an ArrayStorage structure. The inline path made no attempt. | ||
| if (hasAnyArrayStorage(arrayStructure->indexingType())) [[unlikely]] | ||
| result = JSArray::tryCreate(vm, arrayStructure, size); | ||
| else | ||
| result = tryCreateArrayInSizeClassOfInlineAllocation(vm, arrayStructure, size); | ||
| if (!result) [[unlikely]] { | ||
| throwOutOfMemoryError(globalObject, scope); | ||
| OPERATION_RETURN(scope, nullptr); | ||
| } | ||
| } | ||
| OPERATION_RETURN(scope, std::bit_cast<char*>(result)); | ||
| } | ||
|
|
||
| JSC_DEFINE_JIT_OPERATION(operationNewArrayWithSizeAndHint, char*, (JSGlobalObject* globalObject, Structure* arrayStructure, int32_t size, int32_t vectorLengthHint, Butterfly* butterfly)) | ||
| { | ||
| VM& vm = globalObject->vm(); | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.