Skip to content

Commit 3f896f4

Browse files
committed
cranelift: generalize inline copy to all constant-length bulk copies
Per review, move the inline expansion into `raw_bulk_memory_operation`, keyed on a constant byte length, so it also covers `memory.copy` and `table.copy`, not just `array.copy`. It now uses width-agnostic wide accesses (`i8x16` down to `i8`), and `BulkOp::MemoryCopy` carries entity-appropriate flags. Threshold 128 bytes, from measurement (~1.7-2.7x faster up to 128 B, ties by 256). Assisted-by: Claude Code:claude-opus-4-7
1 parent e3ea7f2 commit 3f896f4

4 files changed

Lines changed: 205 additions & 132 deletions

File tree

crates/cranelift/src/func_environ.rs

Lines changed: 106 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -3448,6 +3448,34 @@ impl FuncEnvironment<'_> {
34483448
/// epochs are enabled to break up the copy into a loop of chunks with
34493449
/// preemption checks between them.
34503450
fn raw_bulk_memory_operation(&mut self, builder: &mut FunctionBuilder<'_>, mut op: BulkOp) {
3451+
// Fast path: a copy whose byte length is a small compile-time constant is
3452+
// expanded inline (see `emit_inline_memcpy`), skipping the libcall's fixed
3453+
// per-call cost (a wasm/host transition and an indirect call) that
3454+
// dominates tiny copies. Larger or dynamic copies, and all fills, use the
3455+
// libcall below, whose `memmove` amortizes that cost.
3456+
//
3457+
// The bound is empirical: measured on aarch64, inline is ~1.7-2.7x faster
3458+
// than the libcall through 128 bytes and ties by 256 (cost grows with the
3459+
// length, since every chunk is loaded before any is stored).
3460+
const INLINE_COPY_MAX_BYTES: u64 = 128;
3461+
if let BulkOp::MemoryCopy {
3462+
dst,
3463+
src,
3464+
len,
3465+
flags,
3466+
} = op
3467+
{
3468+
if let Some(bytes) = Self::value_as_const_int(builder, len) {
3469+
if (1..=INLINE_COPY_MAX_BYTES).contains(&bytes) {
3470+
if self.tunables.consume_fuel {
3471+
self.fuel_consumed += bytes as i64;
3472+
}
3473+
self.emit_inline_memcpy(builder, dst, src, bytes, flags);
3474+
return;
3475+
}
3476+
}
3477+
}
3478+
34513479
// Very scientifically chosen. Or, more seriously, this is just an
34523480
// arbitrary number for now. 100k copies of this size locally takes half
34533481
// a second, so seems like a reasonably large chunk size to not hit perf
@@ -3467,7 +3495,7 @@ impl FuncEnvironment<'_> {
34673495
env.epoch_check(builder);
34683496
}
34693497
match *op {
3470-
BulkOp::MemoryCopy { dst, src, len } => {
3498+
BulkOp::MemoryCopy { dst, src, len, .. } => {
34713499
if env.tunables.consume_fuel {
34723500
// Note that fuel is always a 64-bit counter.
34733501
let fuel_consumed = match env.pointer_type() {
@@ -3530,7 +3558,7 @@ impl FuncEnvironment<'_> {
35303558
};
35313559
let has_chunk = builder.ins().icmp(IntCC::UnsignedGreaterThan, len, chunk);
35323560
match *op {
3533-
BulkOp::MemoryCopy { dst, src, len } => {
3561+
BulkOp::MemoryCopy { dst, src, len, .. } => {
35343562
builder.ins().brif(
35353563
has_chunk,
35363564
chunk_block,
@@ -3553,7 +3581,7 @@ impl FuncEnvironment<'_> {
35533581
has_chunk_branch(builder, &op);
35543582

35553583
let append_block_params = |builder: &mut FunctionBuilder<'_>, block, op: &mut _| match op {
3556-
BulkOp::MemoryCopy { dst, src, len } => {
3584+
BulkOp::MemoryCopy { dst, src, len, .. } => {
35573585
*dst = builder.append_block_param(block, pointer_type);
35583586
*src = builder.append_block_param(block, pointer_type);
35593587
*len = builder.append_block_param(block, pointer_type);
@@ -3577,7 +3605,7 @@ impl FuncEnvironment<'_> {
35773605
*op_len = chunk;
35783606
raw_call(self, builder, &op);
35793607
match &mut op {
3580-
BulkOp::MemoryCopy { dst, src, len } => {
3608+
BulkOp::MemoryCopy { dst, src, len, .. } => {
35813609
*dst = builder.ins().iadd(*dst, chunk);
35823610
*src = builder.ins().iadd(*src, chunk);
35833611
*len = builder.ins().isub(remaining_len, chunk);
@@ -3983,12 +4011,18 @@ impl FuncEnvironment<'_> {
39834011
src_entity,
39844012
CheckedEntity::Memory(_) | CheckedEntity::Data { .. }
39854013
));
4014+
// Linear-memory access flags (little-endian, heap alias region),
4015+
// as in `prepare_addr`.
4016+
let flags = ir::MemFlagsData::new()
4017+
.with_endianness(ir::Endianness::Little)
4018+
.with_alias_region(Some(ir::AliasRegion::Heap));
39864019
self.raw_bulk_memory_operation(
39874020
builder,
39884021
BulkOp::MemoryCopy {
39894022
dst: dst_raw_addr,
39904023
src: src_raw_addr,
39914024
len: len_ptr,
4025+
flags,
39924026
},
39934027
);
39944028
Ok(())
@@ -4300,45 +4334,23 @@ impl FuncEnvironment<'_> {
43004334
}
43014335

43024336
// For memcpy, that's easy, just call the intrinsic with the right
4303-
// parameters.
4337+
// parameters. The inline path in `raw_bulk_memory_operation` uses these
4338+
// per-entity flags: GC arrays trap on heap corruption, tables use the
4339+
// table alias region.
43044340
if !type_forbids_memcpy && dst_element_size == src_element_size {
4305-
// Expand small, statically-sized copies inline to skip the
4306-
// `memory_copy` libcall's fixed per-call cost (a wasm/host transition
4307-
// and indirect call), which dominates for tiny copies. Dynamic or
4308-
// larger copies keep the libcall, whose `memmove` amortizes it. Only
4309-
// arrays (in the GC heap) qualify; tables stay on the libcall.
4310-
const INLINE_ARRAY_COPY_MAX_ELEMS: u64 = 8;
4311-
if let CheckedEntity::Array { .. } = dst_entity {
4312-
let elem_ty = match dst_element_size {
4313-
1 => Some(ir::types::I8),
4314-
2 => Some(ir::types::I16),
4315-
4 => Some(ir::types::I32),
4316-
8 => Some(ir::types::I64),
4317-
16 => Some(ir::types::I8X16),
4318-
_ => None,
4319-
};
4320-
if let (Some(elem_ty), Some(n)) =
4321-
(elem_ty, Self::value_as_const_int(builder, copy_len))
4322-
{
4323-
if (1..=INLINE_ARRAY_COPY_MAX_ELEMS).contains(&n) {
4324-
self.emit_inline_array_copy(
4325-
builder,
4326-
dst_elem_addr,
4327-
src_elem_addr,
4328-
elem_ty,
4329-
dst_element_size,
4330-
n,
4331-
);
4332-
return Ok(());
4333-
}
4341+
let flags = match dst_entity {
4342+
CheckedEntity::Array { .. } => {
4343+
ir::MemFlagsData::new().with_trap_code(Some(TRAP_GC_HEAP_CORRUPT))
43344344
}
4335-
}
4345+
_ => ir::MemFlagsData::new().with_alias_region(Some(ir::AliasRegion::Table)),
4346+
};
43364347
self.raw_bulk_memory_operation(
43374348
builder,
43384349
BulkOp::MemoryCopy {
43394350
dst: dst_elem_addr,
43404351
src: src_elem_addr,
43414352
len: dst_copy_byte_len,
4353+
flags,
43424354
},
43434355
);
43444356
return Ok(());
@@ -4415,57 +4427,70 @@ impl FuncEnvironment<'_> {
44154427
Ok(())
44164428
}
44174429

4418-
/// If `value` is a compile-time constant integer (possibly behind a widening
4419-
/// cast of one, as inserted for array indices), return its raw bits.
4420-
/// Out-of-range values are left for the caller to reject.
4421-
fn value_as_const_int(builder: &FunctionBuilder<'_>, mut value: ir::Value) -> Option<u64> {
4422-
loop {
4423-
let inst = builder.func.dfg.value_def(value).inst()?;
4424-
match builder.func.dfg.insts[inst] {
4425-
ir::InstructionData::UnaryImm {
4426-
opcode: ir::Opcode::Iconst,
4427-
imm,
4428-
} => return Some(imm.bits().cast_unsigned()),
4429-
ir::InstructionData::Unary {
4430-
opcode: ir::Opcode::Uextend,
4431-
arg,
4432-
} => value = arg,
4433-
_ => return None,
4434-
}
4435-
}
4430+
/// If `value` is a compile-time constant integer — possibly behind the
4431+
/// widening/narrowing casts and constant multiply that length computations
4432+
/// insert (a byte length is `element_count * element_size`) — return its raw
4433+
/// bits. Out-of-range values are left for the caller to reject.
4434+
fn value_as_const_int(builder: &FunctionBuilder<'_>, value: ir::Value) -> Option<u64> {
4435+
let inst = builder.func.dfg.value_def(value).inst()?;
4436+
Some(match builder.func.dfg.insts[inst] {
4437+
ir::InstructionData::UnaryImm {
4438+
opcode: ir::Opcode::Iconst,
4439+
imm,
4440+
} => imm.bits().cast_unsigned(),
4441+
ir::InstructionData::Unary {
4442+
opcode: ir::Opcode::Uextend | ir::Opcode::Ireduce,
4443+
arg,
4444+
} => Self::value_as_const_int(builder, arg)?,
4445+
ir::InstructionData::BinaryImm64 {
4446+
opcode: ir::Opcode::ImulImm,
4447+
arg,
4448+
imm,
4449+
} => Self::value_as_const_int(builder, arg)?.wrapping_mul(imm.bits().cast_unsigned()),
4450+
_ => return None,
4451+
})
44364452
}
44374453

4438-
/// Expand a small, statically-sized `array.copy` into inline loads then
4439-
/// stores, avoiding the `memory_copy` libcall.
4454+
/// Expand a copy of `bytes` (a small compile-time constant) into inline loads
4455+
/// then stores, avoiding the `memory_copy` libcall.
44404456
///
4441-
/// The copy is bitwise: `elem_ty` is an integer or vector type matching the
4442-
/// element width (`f32`/`f64` use `i32`/`i64`, `v128` uses `i8x16`), so any
4443-
/// fixed-width element works. Every element is loaded before any is stored,
4444-
/// so overlapping ranges keep `array.copy`'s `memmove` semantics. The caller
4445-
/// has already bounds-checked the addresses and length.
4446-
fn emit_inline_array_copy(
4457+
/// The copy is bitwise and element-type agnostic: the byte range is covered
4458+
/// greedily with the widest convenient access (`i8x16` down to `i8`). Every
4459+
/// chunk is loaded before any is stored, so overlapping ranges keep `memmove`
4460+
/// semantics. `flags` must carry the entity-appropriate access flags and must
4461+
/// not assume alignment, since wide chunks may straddle element boundaries.
4462+
/// The caller has already bounds-checked the range.
4463+
fn emit_inline_memcpy(
44474464
&mut self,
44484465
builder: &mut FunctionBuilder<'_>,
44494466
dst_addr: ir::Value,
44504467
src_addr: ir::Value,
4451-
elem_ty: ir::Type,
4452-
elem_size: u32,
4453-
n: u64,
4468+
bytes: u64,
4469+
flags: ir::MemFlagsData,
44544470
) {
4455-
if self.tunables.consume_fuel {
4456-
self.fuel_consumed += n as i64;
4457-
}
4458-
// GC-heap access flags (trap on corruption, no alignment assumed). Not
4459-
// `GC_MEMFLAGS` directly: that constant is gated to the `gc` feature.
4460-
let flags = ir::MemFlagsData::new().with_trap_code(Some(TRAP_GC_HEAP_CORRUPT));
4461-
let stride = i32::try_from(elem_size).unwrap();
4462-
let count = i32::try_from(n).unwrap();
4463-
let mut vals: SmallVec<[ir::Value; 8]> = smallvec![];
4464-
for i in 0..count {
4465-
vals.push(builder.ins().load(elem_ty, flags, src_addr, i * stride));
4471+
const WIDTHS: &[(u64, ir::Type)] = &[
4472+
(16, ir::types::I8X16),
4473+
(8, ir::types::I64),
4474+
(4, ir::types::I32),
4475+
(2, ir::types::I16),
4476+
(1, ir::types::I8),
4477+
];
4478+
let mut chunks: SmallVec<[(i32, ir::Type); 8]> = smallvec![];
4479+
let mut offset = 0u64;
4480+
let mut remaining = bytes;
4481+
for &(width, ty) in WIDTHS {
4482+
while remaining >= width {
4483+
chunks.push((i32::try_from(offset).unwrap(), ty));
4484+
offset += width;
4485+
remaining -= width;
4486+
}
44664487
}
4467-
for (i, val) in (0..count).zip(vals) {
4468-
builder.ins().store(flags, val, dst_addr, i * stride);
4488+
let vals: SmallVec<[ir::Value; 8]> = chunks
4489+
.iter()
4490+
.map(|&(off, ty)| builder.ins().load(ty, flags, src_addr, off))
4491+
.collect();
4492+
for (&(off, _), val) in chunks.iter().zip(vals) {
4493+
builder.ins().store(flags, val, dst_addr, off);
44694494
}
44704495
}
44714496

@@ -5521,11 +5546,14 @@ enum BulkOp {
55215546
/// A `memory.copy` operation, copying memory from `src` to `dst`.
55225547
///
55235548
/// All of `dst`, `src`, and `len` must be pre-validated and inbounds. All
5524-
/// must have type `env.pointer_type()`.
5549+
/// must have type `env.pointer_type()`. `flags` are the access flags used if
5550+
/// the copy is expanded inline (see `emit_inline_memcpy`); they vary by the
5551+
/// entity being copied (GC heap, linear memory, or table).
55255552
MemoryCopy {
55265553
dst: ir::Value,
55275554
src: ir::Value,
55285555
len: ir::Value,
5556+
flags: ir::MemFlagsData,
55295557
},
55305558

55315559
/// A `memory.fill` operation, setting all bytes of `dst` to `val`.

tests/disas/array-copy-inline.wat

Lines changed: 28 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,17 @@
22
;;! test = 'optimize'
33
;;! flags = '-Wgc'
44

5-
;; A statically-sized small `array.copy` is expanded inline as a sequence of
6-
;; loads followed by stores (every element is loaded before any is stored so
7-
;; overlapping ranges still copy correctly), instead of calling the
8-
;; `memory_copy` libcall.
5+
;; A small, constant-length `array.copy` is expanded inline as wide loads
6+
;; followed by stores instead of calling the `memory_copy` libcall. The byte
7+
;; range is covered greedily with the widest convenient access, so 7 `i32`s (28
8+
;; bytes) become an `i8x16` + `i64` + `i32`, and every chunk is loaded before any
9+
;; is stored so overlapping ranges still copy correctly.
910

1011
(module
1112
(type $a (array (mut i32)))
1213

1314
(func $copy (param (ref $a) i32 (ref $a) i32)
14-
(array.copy $a $a (local.get 0) (local.get 1) (local.get 2) (local.get 3) (i32.const 4))
15+
(array.copy $a $a (local.get 0) (local.get 1) (local.get 2) (local.get 3) (i32.const 7))
1516
)
1617
)
1718
;; function u0:0(i64 vmctx, i64, i32, i32, i32, i32) tail {
@@ -26,16 +27,16 @@
2627
;;
2728
;; block0(v0: i64, v1: i64, v2: i32, v3: i32, v4: i32, v5: i32):
2829
;; @002a trapz v2, user16
29-
;; @002a v84 = load.i64 notrap aligned readonly can_move v0+8
30-
;; @002a v8 = load.i64 notrap aligned readonly can_move v84+32
30+
;; @002a v83 = load.i64 notrap aligned readonly can_move v0+8
31+
;; @002a v8 = load.i64 notrap aligned readonly can_move v83+32
3132
;; @002a v7 = uextend.i64 v2
3233
;; @002a v9 = iadd v8, v7
3334
;; @002a v10 = iconst.i64 16
3435
;; @002a v11 = iadd v9, v10 ; v10 = 16
3536
;; @002a v12 = load.i32 user2 readonly v11
3637
;; @002a v14 = uextend.i64 v3
37-
;; v86 = iconst.i64 4
38-
;; @002a v17 = iadd v14, v86 ; v86 = 4
38+
;; v85 = iconst.i64 7
39+
;; @002a v17 = iadd v14, v85 ; v85 = 7
3940
;; @002a v13 = uextend.i64 v12
4041
;; @002a v18 = icmp ugt v17, v13
4142
;; @002a trapnz v18, user17
@@ -45,34 +46,33 @@
4546
;; @002a v30 = iadd v28, v10 ; v10 = 16
4647
;; @002a v31 = load.i32 user2 readonly v30
4748
;; @002a v33 = uextend.i64 v5
48-
;; @002a v36 = iadd v33, v86 ; v86 = 4
49+
;; @002a v36 = iadd v33, v85 ; v85 = 7
4950
;; @002a v32 = uextend.i64 v31
5051
;; @002a v37 = icmp ugt v36, v32
5152
;; @002a trapnz v37, user17
52-
;; @002a v49 = load.i64 notrap aligned v84+40
53-
;; v80 = iconst.i64 20
54-
;; @002a v22 = iadd v9, v80 ; v80 = 20
55-
;; v89 = iconst.i64 2
56-
;; v96 = ishl v14, v89 ; v89 = 2
57-
;; @002a v25 = iadd v22, v96
58-
;; @002a v51 = uadd_overflow_trap v25, v10, user2 ; v10 = 16
53+
;; @002a v49 = load.i64 notrap aligned v83+40
54+
;; v79 = iconst.i64 20
55+
;; @002a v22 = iadd v9, v79 ; v79 = 20
56+
;; v93 = iconst.i64 2
57+
;; v94 = ishl v14, v93 ; v93 = 2
58+
;; @002a v25 = iadd v22, v94
59+
;; v98 = iconst.i64 28
60+
;; @002a v51 = uadd_overflow_trap v25, v98, user2 ; v98 = 28
5961
;; @002a v50 = iadd v8, v49
6062
;; @002a v52 = icmp ugt v51, v50
6163
;; @002a trapnz v52, user2
62-
;; @002a v41 = iadd v28, v80 ; v80 = 20
63-
;; v98 = ishl v33, v89 ; v89 = 2
64-
;; @002a v44 = iadd v41, v98
65-
;; @002a v56 = uadd_overflow_trap v44, v10, user2 ; v10 = 16
64+
;; @002a v41 = iadd v28, v79 ; v79 = 20
65+
;; v96 = ishl v33, v93 ; v93 = 2
66+
;; @002a v44 = iadd v41, v96
67+
;; @002a v56 = uadd_overflow_trap v44, v98, user2 ; v98 = 28
6668
;; @002a v57 = icmp ugt v56, v50
6769
;; @002a trapnz v57, user2
68-
;; @002a v58 = load.i32 user2 v44
69-
;; @002a v59 = load.i32 user2 v44+4
70-
;; @002a v60 = load.i32 user2 v44+8
71-
;; @002a v61 = load.i32 user2 v44+12
70+
;; @002a v58 = load.i8x16 user2 v44
71+
;; @002a v59 = load.i64 user2 v44+16
72+
;; @002a v60 = load.i32 user2 v44+24
7273
;; @002a store user2 v58, v25
73-
;; @002a store user2 v59, v25+4
74-
;; @002a store user2 v60, v25+8
75-
;; @002a store user2 v61, v25+12
74+
;; @002a store user2 v59, v25+16
75+
;; @002a store user2 v60, v25+24
7676
;; @002e jump block1
7777
;;
7878
;; block1:

0 commit comments

Comments
 (0)