Skip to content

Conversation

jsgf
Copy link
Contributor

@jsgf jsgf commented Oct 1, 2025

As part of Rust's move semantics, the compiler will generate memory copy operations to move objects about. These are generally pretty small, and the backend is good at optimizing them. But sometimes, if the type is large, they can end up being surprisingly expensive. In such cases, you might want to pass them by reference, or Box them up.

However, these moves are also invisible to profiling. At best they appear as a memcpy, but one memcpy is basically indistinguishable from another, and its very hard to know that 1) it's actually a compiler-generated copy, and 2) what type it pertains to.

This PR adds two new pseudo-intrinsic functions in core::intrinsics:

pub fn compiler_move<T, const SIZE: usize>(_src: *const T, _dst: *mut T);
pub fn compiler_copy<T, const SIZE: usize>(_src: *const T, _dst: *mut T);

These functions are never actually called however. A MIR transform pass -- instrument_moves.rs -- will locate all Operand::Move/Copy operations, and modify their source location to make them appear as if they had been inlined from compiler_move/_copy.

These functions have two generic parameters: the type being copied, and its size in bytes. This should make it very easy to identify which types are being expensive in your program (both in aggregate, and at specific hotspots). The size isn't strictly necessary since you can derive it from the type, but it's small and it makes it easier to understand what you're looking at.

This functionality is only enabled if you have debug info generation enabled, and also set the -Zinstrument-moves option.

It does not instrument all moves. By default it will only annotate ones for types over 64 bytes. The -Zinstrument-moves-size-limit specifies the size in bytes to start instrumenting for.

This has minimal impact on the size of debugging info. For rustc itself, the overall increase in librustc_driver*.so size is around .05% for 65 byte limit, 0.004% for 1025 byte limit, and a worst case of 0.6% for an 8 byte limit.

There's no effect on generated code, it only adds debug info.

As an example of a backtrace:

Breakpoint 1.3, __memcpy_avx512_unaligned_erms () at ../sysdeps/x86_64/multiarch/memmove-vec-unaligned-erms.S:255
255	ENTRY_P2ALIGN (MEMMOVE_SYMBOL (__memmove, unaligned_erms), 6)
(gdb) bt
 # 0  __memcpy_avx512_unaligned_erms () at ../sysdeps/x86_64/multiarch/memmove-vec-unaligned-erms.S:255
 # 1  0x0000555555590e7e in core::intrinsics::compiler_copy<[u64; 1000], 8000> () at library/core/src/intrinsics/mod.rs:10
 # 2  t::main () at t.rs:10

@rustbot
Copy link
Collaborator

rustbot commented Oct 1, 2025

Some changes occurred to MIR optimizations

cc @rust-lang/wg-mir-opt

Some changes occurred to the intrinsics. Make sure the CTFE / Miri interpreter
gets adapted for the changes, if necessary.

cc @rust-lang/miri, @RalfJung, @oli-obk, @lcnr

@rustbot rustbot added S-waiting-on-review Status: Awaiting review from the assignee but also interested parties. T-compiler Relevant to the compiler team, which will review and decide on the PR/issue. T-libs Relevant to the library team, which will review and decide on the PR/issue. labels Oct 1, 2025
@rustbot
Copy link
Collaborator

rustbot commented Oct 1, 2025

r? @davidtwco

rustbot has assigned @davidtwco.
They will have a look at your PR within the next two weeks and either review your PR or reassign to another reviewer.

Use r? to explicitly pick a reviewer

@rustbot

This comment has been minimized.

@jsgf jsgf force-pushed the move-copy-debug branch from 960847e to 5cc27d4 Compare October 1, 2025 02:22
@jsgf
Copy link
Contributor Author

jsgf commented Oct 1, 2025

I'm not really sure whether Statement::Assign's rvalues covers all the interesting cases or not. I'd like to make sure it covers:

  • parameter passing I was missing TerminatorKind::Call
  • returns
  • assignment
  • initialization
  • ...anything else?

@rust-log-analyzer

This comment has been minimized.

@jsgf jsgf force-pushed the move-copy-debug branch from 5cc27d4 to db64712 Compare October 1, 2025 04:37
@rust-log-analyzer

This comment has been minimized.

@RalfJung
Copy link
Member

RalfJung commented Oct 1, 2025

Interesting idea!

Is it really worth distinguishing moves and copies? That doesn't make much of a difference for the runtime code, it's mostly a type system thing.

I'm not really sure whether Statement::Assign's rvalues covers all the interesting cases or not. I'd like to make sure it covers:

In MIR these will be spread across various places. The codegen backend would have an easier time centralizing all the ways in which operand uses get codegen'd as memcpy. But I am not sure if there's still a good way to adjust debuginfo there...

Isn't there a mutating MIR visitor you can use that traverses all operands?

Comment on lines 3318 to 3340
/// Compiler-generated move operation - never actually called.
/// Used solely for profiling and debugging visibility.
///
/// This function serves as a symbolic marker that appears in stack traces
/// when rustc generates move operations, making them visible in profilers.
/// The SIZE parameter encodes the size of the type being moved in the function name.
#[rustc_force_inline]
#[rustc_diagnostic_item = "compiler_move"]
pub fn compiler_move<T, const SIZE: usize>(_src: *const T, _dst: *mut T) {
unreachable!("compiler_move should never be called - it's only for debug info")
}

/// Compiler-generated copy operation - never actually called.
/// Used solely for profiling and debugging visibility.
///
/// This function serves as a symbolic marker that appears in stack traces
/// when rustc generates copy operations, making them visible in profilers.
/// The SIZE parameter encodes the size of the type being copied in the function name.
#[rustc_force_inline]
#[rustc_diagnostic_item = "compiler_copy"]
pub fn compiler_copy<T, const SIZE: usize>(_src: *const T, _dst: *mut T) {
unreachable!("compiler_copy should never be called - it's only for debug info")
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These aren't intrinsics so I don't think this is the best place for them. The file is already too big anyway.^^

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I wasn't sure exactly where to put them. Originally I had the idea of actually making them real functions implementing copy & move in terms of calls to them, but that seemed more fiddly than it's worth.

@jsgf
Copy link
Contributor Author

jsgf commented Oct 1, 2025

Is it really worth distinguishing moves and copies? That doesn't make much of a difference for the runtime code, it's mostly a type system thing.

I think in practice it's useful - I've seen very large structures being made Copy just because all their fields allow it and then being copied around unexpectedly. It would be nice to be able to see that, and distinguish it from regular Move.

The codegen backend would have an easier time centralizing all the ways in which operand uses get codegen'd as memcpy. But I am not sure if there's still a good way to adjust debuginfo there...

That was actually my first attempt but I ended up with a stream of mysterious crashes/assertion failures from within the guts of llvm. Doing the manipulations at the MIR level turned out to be much more straightforward.

Isn't there a mutating MIR visitor you can use that traverses all operands?

I'll take another look.

@jsgf jsgf force-pushed the move-copy-debug branch from db64712 to af357e2 Compare October 1, 2025 08:10
@jsgf
Copy link
Contributor Author

jsgf commented Oct 1, 2025

@RalfJung

  • I created core::profiling to hold compiler_copy/compiler_move
  • I looked at the visit_operand visitor; it's appealing but it doesn't give access to change the containing source info.

@rust-log-analyzer

This comment has been minimized.

@jsgf
Copy link
Contributor Author

jsgf commented Oct 1, 2025

This has minimal impact on the size of debugging info. For rustc itself, the overall increase in librustc_driver*.so size is around .05% for 65 byte limit, 0.004% for 1025 byte limit, and a worst case of 0.6% for an 8 byte limit.

I was missing parameter passing moves the first time around, so it's a little larger now: about 0.2% for 65 byte, about (almost nothing) for 1024 and closer to 1% for 8 byte.

As part of Rust's move semantics, the compiler will generate memory copy
operations to move objects about. These are generally pretty small, and
the backend is good at optimizing them. But sometimes, if the type is
large, they can end up being surprisingly expensive. In such cases, you
might want to pass them by reference, or Box them up.

However, these moves are also invisible to profiling. At best they
appear as a `memcpy`, but one memcpy is basically indistinguishable from
another, and its very hard to know that 1) it's actually a
compiler-generated copy, and 2) what type it pertains to.

This PR adds two new pseudo-functions in `core::profiling`:
```
pub fn compiler_move<T, const SIZE: usize>(_src: *const T, _dst: *mut T);
pub fn compiler_copy<T, const SIZE: usize>(_src: *const T, _dst: *mut T);
```
These functions are never actually called however. A MIR transform
pass -- `instrument_moves.rs` -- will locate all `Operand::Move`/`Copy`
operations, and modify their source location to make them appear as if
they had been inlined from `compiler_move`/`_copy`.

These functions have two generic parameters: the type being copied, and
its size in bytes. This should make it very easy to identify which types
are being expensive in your program (both in aggregate, and at specific
hotspots). The size isn't strictly necessary since you can derive it
from the type, but it's small and it makes it easier to understand what
you're looking at.

This functionality is only enabled if you have debug info generation
enabled, and also set the `-Zinstrument-moves` option.

It does not instrument all moves. By default it will only annotate ones
for types over 64 bytes. The `-Zinstrument-moves-size-limit` specifies
the size in bytes to start instrumenting for.

This has minimal impact on the size of debugging info. For rustc itself,
the overall increase in librustc_driver*.so size is around .05% for 65
byte limit, 0.004% for 1025 byte limit, and a worst case of 0.6% for an
8 byte limit.

There's no effect on generated code, it only adds debug info.

As an example of a backtrace:
```
Breakpoint 1.3, __memcpy_avx512_unaligned_erms () at ../sysdeps/x86_64/multiarch/memmove-vec-unaligned-erms.S:255
255	ENTRY_P2ALIGN (MEMMOVE_SYMBOL (__memmove, unaligned_erms), 6)
(gdb) bt
 # 0  __memcpy_avx512_unaligned_erms () at ../sysdeps/x86_64/multiarch/memmove-vec-unaligned-erms.S:255
 # 1  0x0000555555590e7e in core::profiling::compiler_copy<[u64; 1000], 8000> () at library/core/src/profiling.rs:27
 # 2  t::main () at t.rs:10
```
@jsgf jsgf force-pushed the move-copy-debug branch from af357e2 to 2ba9b47 Compare October 1, 2025 19:06
@rust-log-analyzer
Copy link
Collaborator

The job x86_64-gnu-llvm-20 failed! Check out the build log: (web) (plain enhanced) (plain)

Click to see the possible cause of the failure (guessed by this bot)
test [mir-opt] tests/mir-opt/unusual_item_types.rs ... ok

failures:

---- [mir-opt] tests/mir-opt/instrument-moves/iter.rs stdout ----
7         debug iter => _1;
8         scope 2 (inlined test_impl_trait_arg::<std::iter::Chain<Map<std::array::IntoIter<u64, 5>, fn(u64) -> u64>, Map<std::array::IntoIter<u64, 5>, fn(u64) -> u64>>>) {
9             debug iter => _1;
-             scope 3 (inlined core::profiling::compiler_move::<std::iter::Chain<Map<std::array::IntoIter<u64, 5>, fn(u64) -> u64>, Map<std::array::IntoIter<u64, 5>, fn(u64) -> u64>>, 128>) {
+             scope 3 (inlined core::profiling::compiler_move::<std::iter::Chain<Map<std::array::IntoIter<u64, 5>, fn(u64) -> u64>, Map<std::array::IntoIter<u64, 5>, fn(u64) -> u64>>, 104>) {
11             }
12         }
13     }


Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
S-waiting-on-review Status: Awaiting review from the assignee but also interested parties. T-compiler Relevant to the compiler team, which will review and decide on the PR/issue. T-libs Relevant to the library team, which will review and decide on the PR/issue.
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants