Skip to content

Cranelift: box a few fields in MachBuffer/MachBufferFinalized to avoid memmoves. - #14300

Open
cfallin wants to merge 1 commit into
bytecodealliance:mainfrom
cfallin:machbuffer-avoid-moves
Open

Cranelift: box a few fields in MachBuffer/MachBufferFinalized to avoid memmoves.#14300
cfallin wants to merge 1 commit into
bytecodealliance:mainfrom
cfallin:machbuffer-avoid-moves

Conversation

@cfallin

@cfallin cfallin commented Sep 8, 2026

Copy link
Copy Markdown
Member

Issue initially raised by alexcrichton at last week's Cranelift meeting: we have SmallVecs that are (ironically) quite large in MachBuffer, and the "finalized" split causes significant memmoves when the MachBufferFinalized is put together with pieces of the MachBuffer during finalization.

The initial design intent (six years ago!) was to avoid allocations for the common case of a small function compilation: a MachBuffer would contain enough buffer space for the machine code + metadata for anything up to, say, a kilobyte of machine code. But that benefit was lost when we moved to the "finalization" design.

The split itself is necessary: it captures an algorithmic finalization step, and that data that we have before and after differs. (Specifically, we translate label indices into buffer offsets inside of several record types; those are different types, we shouldn't type-pun them, and we have to make a pass through all the data anyway so those vecs are not a simple bulk data move.)

However many of the metadata arrays are simple passthroughs: for example, the machine-code buffer itself.

This is a simple/mechanical refactor that puts fields that don't change type during finalization in a MachBufferInner, puts that in a Box, and then holds it from either the MachBuffer or MachBufferFinalized. Thus the finalization can move one pointer over instead of kilobytes of buffers.

On a quick Sightglass run with default.suite, I see one benchmark's compilation time move:

compilation :: cycles :: spidermonkey-markdown

    Δ = 16405327.75 ± 16285800.71 (confidence = 99%)

    modified.so is 1.00x to 1.02x faster than base.so!

    ┌────────────┬────────────┬───────────────┬────────────┬─────────────┐
    │ Min        │ Max        │ Mean          │ Median     │ Engine      │
    ├────────────┼────────────┼───────────────┼────────────┼─────────────┤
    │ 1258771422 │ 1408382397 │ 1333799348.93 │ 1343472392 │ base.so     │
    ├────────────┼────────────┼───────────────┼────────────┼─────────────┤
    │ 1254241200 │ 1405715279 │ 1317394021.18 │ 1313587478 │ modified.so │
    └────────────┴────────────┴───────────────┴────────────┴─────────────┘

so a mean ~1% speedup.

There is definitely further work to do to try to reuse allocations across compilations by holding something in the Context, as we do with e.g. regalloc2::Ctx. The complication there is that MachBuffer is monomorphized on I because it holds I::LabelUses and those can differ between architectures; and cranelift_codegen::Context can be used to recompile for different ISAs with each compile invocation, so the actual type can necessarily differ between invocations. I suppose we could collapse all individual LabelUse enums into one shared one that has ISA-prefixed names for each arm, then remove the monomorphization in MachBuffer; but I'll leave that for future work.

…avoid memmoves.

Issue initially raised by alexcrichton at last week's Cranelift
meeting: we have `SmallVec`s that are (ironically) quite large in
`MachBuffer`, and the "finalized" split causes significant `memmove`s
when the `MachBufferFinalized` is put together with pieces of the
`MachBuffer` during finalization.

The initial design intent (six years ago!) was to avoid allocations for
the common case of a small function compilation: a `MachBuffer` would
contain enough buffer space for the machine code + metadata for anything
up to, say, a kilobyte of machine code. But that benefit was lost when
we moved to the "finalization" design.

The split itself is necessary: it captures an algorithmic finalization
step, and that data that we have before and after differs.
(Specifically, we translate label indices into buffer offsets inside of
several record types; those are different types, we shouldn't type-pun
them, and we have to pass through all the data anyway so those vecs are
not a simple bulk data move.)

However many of the metadata arrays are simple passthroughs: for
example, the machine-code buffer itself.

This is a simple/mechanical refactor that puts fields that don't change
type during finalization in a `MachBufferInner`, puts that in a `Box`,
and then holds it from either the `MachBuffer` or `MachBufferFinalized`.
Thus the finalization can move one pointer over instead of kilobytes of
buffers.

On a quick Sightglass run with `default.suite`, I see one benchmark's
compilation time move:

```plain
compilation :: cycles :: spidermonkey-markdown

    Δ = 16405327.75 ± 16285800.71 (confidence = 99%)

    modified.so is 1.00x to 1.02x faster than base.so!

    ┌────────────┬────────────┬───────────────┬────────────┬─────────────┐
    │ Min        │ Max        │ Mean          │ Median     │ Engine      │
    ├────────────┼────────────┼───────────────┼────────────┼─────────────┤
    │ 1258771422 │ 1408382397 │ 1333799348.93 │ 1343472392 │ base.so     │
    ├────────────┼────────────┼───────────────┼────────────┼─────────────┤
    │ 1254241200 │ 1405715279 │ 1317394021.18 │ 1313587478 │ modified.so │
    └────────────┴────────────┴───────────────┴────────────┴─────────────┘
```

so a mean ~1% speedup.

There is definitely further work to do to try to *reuse* allocations across
compilations by holding something in the `Context`, as we do with e.g.
`regalloc2::Ctx`. The complication there is that `MachBuffer` is monomorphized
on `I` because it holds `I::LabelUse`s and those can differ between
architectures; and `cranelift_codegen::Context` can be used to recompile for
different ISAs with each `compile` invocation, so the *actual* type can
necessarily differ between invocations. I suppose we could collapse all
individual `LabelUse` enums into one shared one that has ISA-prefixed names for
each arm, then remove the monomorphization in `MachBuffer`; but I'll leave that
for future work.
@cfallin
cfallin requested a review from a team as a code owner September 8, 2026 17:53
@cfallin
cfallin requested review from alexcrichton and removed request for a team September 8, 2026 17:53
@github-actions github-actions Bot added cranelift Issues related to the Cranelift code generator cranelift:area:machinst Issues related to instruction selection and the new MachInst backend. labels Sep 8, 2026
@alexcrichton
alexcrichton requested review from fitzgen and removed request for alexcrichton September 9, 2026 14:38
@alexcrichton

Copy link
Copy Markdown
Member

I'll defer this to @fitzgen since my vision for what to do here differs pretty greatly.

The split itself is necessary: it captures an algorithmic finalization step, and that data that we have before and after differs.

Personally I don't agree with this -- the type punning is exactly what we do in Wasmtime for things like enum EngineOrModuleTypeIndex. My personal historical experience is that type state is basically never worth it and is more runtime overhead than necessary while also being a pretty severe API design constranit. I would be surprised if a memcpy was faster than update-in-place -- that's touching 2x more memory than an in-place update. My impression though is there's not much interest in exploring this concern, and overall it's pretty minor, so I won't take this further.

I'll also note that I was benchmarking a test case from oss-fuzz, not something like spidermonkey.wasm. I wouldn't expect much speedup on normal wasms, but foo.wasm.txt for example is ~100k empty functions (a fuzz test case)

@cfallin

cfallin commented Sep 9, 2026

Copy link
Copy Markdown
Member Author

Thanks @alexcrichton -- I want to make sure I fully understand your position here so this is useful input. Mapping the type structure in a little more detail, the actual types that change are all MachLabel -> CodeOffset. Both are newtyped u32s; we could do a bitpacked enum, steal the upper bit and support up to 2GiB code instead of 4GiB. Thinking about this further I'd be fine with this.

Part of the "type state", though, is really a builder pattern: MachBuffer contains the in-progress state (labels at last point, current source loc, ...) and we drop that when moving to the MachBufferFinalized. We do have builder patterns all over the place (e.g. Function and FunctionBuilder). Are you saying that that separation is not worth encoding in the type system?

(If we keep that distinction, then I think the above basically means either everything goes into the MachBufferInner here, or MachBuffer -> MachBufferBuilder which owns a MachBuffer until we destruct the outer builder and return the MachBuffer. Box to taste to keep that last bit from memmove'ing.)

The last bit of work needed to make MachBuffer independent of VCodeInst is to consolidate LabelUses into one concrete type rather than using I::LabelUse, remove the trait-impl machinery there, and have one single implementation somewhere that understands all ISAs we support. That's kind of awkward (it makes some ISA knowledge centrally-located) but I guess we already do it for Reloc and that's not the end of the world. Is that what you had in mind or something else?

@cfallin

cfallin commented Sep 9, 2026

Copy link
Copy Markdown
Member Author

(To make sure it's explicit: the reason I ask for more details of what you were thinking here is because you had mentioned something about dyn Traits, etc., last week; I would be concerned about efficiency of such an approach but I'm also not seeing where it's necessary)

@alexcrichton

Copy link
Copy Markdown
Member

I was roughly expecting an enum for differentiating between CodeOffset and MachLabel, but if code size is a concern then something bit-packed could work. Basically just runtime state saying which-is-which plus panics if the wrong one is accessed or found -- which is similar to how other "should have happened prior"-style errors show up throughout compilation.

I agree we have builders elsewhere, and no I'm not advocating for their removal. Builders are often places to hang methods and take advantage of unique ownership in addition to shuffling around internals. What I find typestate is not helpful for is deep within a structure there's a single field that needs flipping but everything else is more-or-less the same. I also find it's not helpful when the type needs to be referred to in a lot of places and may be stored in a number of locations. Here MachBuffer doesn't benefit too too much IMO from the type state (just a few fields flipping) and it's also stored in a fair number of places which I think we should be able to refactor and massage as well. I'm not trying to necessarily advocate for a general framework for all types, but I don't personally think typestate carries its weight here.

For I: VCodeInst, to restate what I was saying in the meeting last week, what I was imagining was a Box<dyn Any> to punch through the dyn TargetIsa boundary. There'd for example be a TargetIsa::make_buffer() -> Box<dyn Any> which would internally be a MachBuffer<I>. Later during compilation it'd take &mut dyn Any which would internally be downcast to MachBuffer<I>. The finalized state I think was able to be decoupled from I so that wouldn't need anything there. Basically I agree it would be nice to avoid the one-LabelUse-for-all-backends and I was thinking we could still preserve that.

To additionally clarify, though, the original goal I had was sharing buffers across compilation in the backend. That's already something we do with cranelift-frontend contexts and such, but nothing in the backend is sharing any buffers. For example VCode is freshly-allocated on all compilations with fresh buffers and I suspect might benefit from buffer reuse for the same reasons we found it beneficial to reuse regalloc contexts and other compilation contexts. I was hoping to change the argument to TargetIsa::compile_function to not just take a regalloc2::Context but a "backend context", probably as a &mut dyn Any to hide the I: VCodeInst, and then internally that would reuse buffers as necessary.

@cfallin

cfallin commented Sep 9, 2026

Copy link
Copy Markdown
Member Author

OK, that's a useful design vision, thanks. I think the key bit getting in the way of any reuse, though, is this thing I mentioned above:

There is definitely further work to do to try to reuse allocations across compilations by holding something in the Context, as we do with e.g. regalloc2::Ctx. The complication there is that MachBuffer is monomorphized on I because it holds I::LabelUses and those can differ between architectures; and cranelift_codegen::Context can be used to recompile for different ISAs with each compile invocation, so the actual type can necessarily differ between invocations.

(and the same applies even moreso to VCode<I>)

In other words, simply putting things behind a box-of-any and downcasting does not cut it: if we keep the ISA-specific types distinct, they can have different representations (and indeed the size of MachInst differs between our ISAs); and the reuse scope, "one compiler context", permits recompilation with different ISAs, so we simply cannot reuse the underlying objects. (Or stated differently, there's no "downcasting trick" because it's not just newtypes on the surface; these really are different types)

We could push the TargetIsa arg further forward in the pipeline -- at construction of the Context and then hold it somehow, so a given compiler instance is tied to one ISA only -- but that's a deeper API change to Cranelift and I haven't worked through all the implications.

@alexcrichton

Copy link
Copy Markdown
Member

It's true, yes a boxed trait object wouldn't work across ISAs. I don't personally consider that much of a downside though because that seems like a pretty niche edge case that's not worth acting as a foundation for the API design. For example in Wasmtime I don't think we'd ever hit that.

@cfallin

cfallin commented Sep 9, 2026

Copy link
Copy Markdown
Member Author

OK, yeah, that's fair. It would again be a public API change but that's fine.

One thing I forgot to respond to on this point:

I agree we have builders elsewhere, and no I'm not advocating for their removal. Builders are often places to hang methods and take advantage of unique ownership in addition to shuffling around internals. What I find typestate is not helpful for is deep within a structure there's a single field that needs flipping but everything else is more-or-less the same. I also find it's not helpful when the type needs to be referred to in a lot of places and may be stored in a number of locations. Here MachBuffer doesn't benefit too too much IMO from the type state (just a few fields flipping) and it's also stored in a fair number of places which I think we should be able to refactor and massage as well. I'm not trying to necessarily advocate for a general framework for all types, but I don't personally think typestate carries its weight here.

I guess it's subjective to some degree but the MachBuffer / MachBufferFinalized builder pattern is doing more than "just a few fields flipping": essentially all of the mutable builder API makes sense only on the object being built, not once it has been finalized. Yes we could carry a bool and panic on calls that are "out of phase" but it seems like an odd approach to take in a language that otherwise prefers static type-level distinctions, especially if we box the innards so they don't actually move when the builder is finalized. So: there's no performance difference (once we have this PR + move the label-to-offset rewrites into an in-place scheme and put them in the box too), and one alternative is strictly safer / harder to hold wrong at the type level. Why wouldn't we do the latter?

@alexcrichton

Copy link
Copy Markdown
Member

It would again be a public API change but that's fine.

Well, again, I'm trying to take myself off the critical path of this PR. We've historically pretty rarely taken into account API changes when considering PRs, but it can of course be considered just like everything else. I wouldn't agree with such a consideration myself, but this is minor enough that I don't see the need to push the case further.

Why wouldn't we do the latter?

Sorry if this has caused confusion, but I'm realizing that I've got a picture of typestate in my head which is probably different than what you've got. When I've been saying typestate I've been specifically referring to the CompilePhase trait, not the builder -> finalized pattern. I realize both are a form of typestate, however, and that's where I'm realizing it'd be good to clarify.

Which is to say, I'm not saying we should remove the builder -> finalized pattern. I'm saying we should remove the stencil -> finalized pattern. That's not to say the existing builder pattern couldn't be improved, there's still tons of buffers in MachBuffer that are fresh for each copy and would probably benefit from being shared across buffers, but that doesn't affect the builder -> finalized pattern and would instead just be like an argument to constructing the builder if that route were taken.

@cfallin

cfallin commented Sep 9, 2026

Copy link
Copy Markdown
Member Author

Ah! OK, yes, sorry, I did think you were talking about the builder distinction (different types for different phases) rather than compile phase (also different types for different phases, albeit a zero-sized sentinel type). I had mapped "just a few fields flipping" to the work in finish().

I'm less strongly attached to CompilePhase: it's nice for correctness in the incremental caching work (where it was introduced) but applies only to SourceLoc relativization, i.e. not on the critical path for sandboxing or anything, and we could actually do the same bitpacked-enum trick there and debug-assert when unwrapping to an absolute loc or something.

Well, again, I'm trying to take myself off the critical path of this PR. We've historically pretty rarely taken into account API changes when considering PRs, but it can of course be considered just like everything else. I wouldn't agree with such a consideration myself, but this is minor enough that I don't see the need to push the case further.

Right, I'm fine making the API change on Context -- not arguing against it per-se, just an offhand comment noting that the scope had increased. I guess to complete the thought: I don't think anyone actually relies on being able to compile the same function to multiple ISAs with one instance of a Context; threading through isa: &dyn TargetIsa as it is to individual methods is probably (?) a historical artifact rather than an intentional design, if I had to guess, though it's been that way for forever. So we probably don't even inconvenience anyone else by doing this.

Sorry to keep dragging you back in btw -- just wanted to make sure I understood your concerns + perspectives. I think I have a good idea now where you're coming from.

I'll update this PR in the next few days per the above. Thanks!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

cranelift:area:machinst Issues related to instruction selection and the new MachInst backend. cranelift Issues related to the Cranelift code generator

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants