iTranslated by AI
Tackling X68000 Raster Scrolling with Rust
In this article, I will perform performance tuning on the Rust version of the raster scrolling coded in the previous article[1].
Through a process of trial and error in performance tuning, I will explore the limits of the processing capacity of executables built via Wasm.
How to Proceed with Performance Tuning
Currently, to build the X68000 executable format from Rust, the build is performed via Wasm as shown in the diagram below[2].
For this performance tuning, rather than immediately modifying the Rust code or wasm2c, I will adopt a strategy of modifying the converted C source code to verify the effects, and then incorporate the successful changes into wasm2c on the left side of the diagram.
Performance Tuning Hotspots
I have identified the following two areas where there is room for performance tuning:
- IO peek and poke processing
- Little-endian access to linear memory
In raster scrolling processing, it is necessary to improve the speed of the part that polls the horizontal sync signal. In the previous implementation, the peek function was called every time access was made, which meant the performance required for this section could not be achieved.
Regarding linear memory access, it must be accessed in little-endian, which acts as a bottleneck. Wasm's specification stores data in linear memory in little-endian, and while it is difficult to overturn this specification itself, I will aim for processing with as little penalty as possible by optimizing the conversion process.
Improving IO peek/poke processing
First, I will work on improving peek and poke. Instead of calling functions defined as external as before, I will change them to code that embeds volatile access inline, as shown below.
// uint32_t __wbg_x68kbpeek_a51a25fcf8ed5e80(uint32_t);
static inline uint8_t __wbg_x68kbpeek_a51a25fcf8ed5e80(uint32_t adr) {
return *(volatile uint8_t*)adr;
}
//void __wbg_x68kwpoke_3a423ab8e048655d(uint32_t, uint32_t);
static inline void __wbg_x68kwpoke_3a423ab8e048655d(uint32_t adr, uint16_t value) {
*(volatile uint16_t*)adr = value;
}
This is a bit of a "cheat" solution right off the bat, but let's see how the assembly code for polling the horizontal sync changes.
lea ___wbg_x68kbpeek_a51a25fcf8ed5e80,a4 * lea __wbg_x68kbpeek_a51a25fcf8ed5e80,%a4 |, tmp70
_?L225: *.L225:
*| raster.c:4984: l7 = __wbg_x68kbpeek_a51a25fcf8ed5e80(l7);
move.l #15237121,-(sp) * move.l #15237121,-(%sp) |,
jbsr (a4) * jsr (%a4) | tmp70
*| raster.c:4988: if (l7) {
addq.l #4,sp * addq.l #4,%sp |,
tst.b d0 * tst.b %d0 | tmp89
jbpl _?L225 * jpl .L225 |
In the inline version, the code for function calls, argument stack operations, and return value checks is omitted, resulting in code with just move.b and jbpl (branch if plus).
_?L224: *.L224:
*| raster.c:184: return *(volatile uint8_t*)adr;
move.b 15237121,d0 * move.b 15237121,%d0 | MEM[(volatile uint8_t *)15237121B], _45
*| raster.c:5002: if (l7) {
jbpl _?L224 * jpl .L224 |
Running this produces the following result. A sine wave is now displayed for up to 2 cycles. The goal is 4 cycles, but this is a significant step forward compared to last time.

Improving Little-Endian Access
Next, I will improve little-endian access to linear memory. The goal is to optimize the processing within the loop for each raster. The relevant Rust code looks like this:
while (x68k_bpeek(X68K_MMIO_GPIP) & X68K_MMIO_GPIP_BIT_HSYNC) == 0x00 {
}
x68k_wpoke(X68K_MMIO_CRTC_GRAPH_X0, wave_table[phase]);
y_posizion += 1;
if y_posizion >= 256 { break; }
phase = (phase + 1) & 0x3F;
while (x68k_bpeek(X68K_MMIO_GPIP) & X68K_MMIO_GPIP_BIT_HSYNC) != 0x00 {
}
When built, this part becomes the following assembly code:
_?L224: *.L224:
*| raster.c:184: return *(volatile uint8_t*)adr;
move.b 15237121,d0 * move.b 15237121,%d0 | MEM[(volatile uint8_t *)15237121B], _45
*| raster.c:5002: if (l7) {
jbpl _?L224 * jpl .L224 |
*| raster.c:5008: if (l10) {
cmp.l d4,d3 * cmp.l %d4,%d3 | l5, l0
jbcc _?L225 * jcc .L225 |
*| raster.c:5013: l15 <<= l16 & 0x1F;
move.l d3,d0 * move.l %d3,%d0 | l0, l15
add.l d3,d0 * add.l %d3,%d0 | l0, l15
*| raster.c:5014: l14 += l15;
add.l d5,d0 * add.l %d5,%d0 | l5, l14
*| raster.c:5015: l16 = load16_align1((const uint16_t *)&m0[l14 + UINT32_C(0)]);
add.l _m0,d0 * add.l m0,%d0 | m0, l14
move.l d0,-(sp) * move.l %d0,-(%sp) | l14,
jbsr (a4) * jsr (%a4) | tmp77
*| raster.c:189: *(volatile uint16_t*)adr = value;
move.w d0,15204376 * move.w %d0,15204376 | _15, MEM[(volatile uint16_t *)15204376B]
*| raster.c:5020: if (l14) {
subq.l #1,a3 * subq.l #1,%a3 |, ivtmp_17
addq.l #4,sp * addq.l #4,%sp |,
cmp.w #0,a3 * cmp.w #0,%a3 |, ivtmp_17
jbeq _?L223 * jeq .L223 |
_?L227: *.L227:
*| raster.c:184: return *(volatile uint8_t*)adr;
move.b 15237121,d0 * move.b 15237121,%d0 | MEM[(volatile uint8_t *)15237121B], _46
*| raster.c:5031: if (l21) {
jbmi _?L227 * jmi .L227 |
In the middle, you can find the part where the value to be written to the scroll register is loaded from linear memory using the load16_align1 function. I will change this function to the following inline function. The original processing content is kept as a comment for comparison.
static inline uint16_t load16_align1(const uint16_t *ptr) {
// uint16_t val;
// rmemcpy(&val, ptr, sizeof(val));
// return val;
return __builtin_bswap16(*ptr);
}
The alignN suffix attached to the end of the load and store function names comes from the alignment hints of Wasm's load and store instructions. This tells us that the address in linear memory accessed by this instruction is aligned to a power of 2 (2^N). In other words, an align0 function indicates that it may access odd addresses, while functions with an alignment hint of align1 or higher guarantee word alignment. Since word alignment is guaranteed on the m68k, we can just access it directly, so I have written the code to read from the address pointed to by the pointer and perform a byte swap as shown above.
The assembly code generated before and after the change is as follows:
*| raster.c:5015: l16 = load16_align1((const uint16_t *)&m0[l14 + UINT32_C(0)]);
add.l _m0,d0 * add.l m0,%d0 | m0, l14
move.l d0,-(sp) * move.l %d0,-(%sp) | l14,
jbsr (a4) * jsr (%a4) | tmp77
*| raster.c:28: return __builtin_bswap16(*ptr);
moveq #0,d2 * moveq #0,%d2 | MEM[(const uint16_t *)_14]
move.w (a0,a1.l),d2 * move.w (%a0,%a1.l),%d2 | MEM[(const uint16_t *)_14], MEM[(const uint16_t *)_14]
ror.w #8,d2 * ror.w #8,%d2 |, _46
Let's run it immediately.

Huh? It doesn't seem to have any effect at all.
Fn, FnOnce, and FnMut
Since tilting my head in confusion won't lead to progress, I'll use a debugger to find the processes that are taking time.
While stepping through the execution, I noticed something. It seems the load function is being called many times after detecting the horizontal sync.
Why, even though I inlined it?
The assembly code for the part that was actually being executed is shown below.
_?L214: *.L214:
*| raster.c:184: return *(volatile uint8_t*)adr;
move.b 15237121,d0 * move.b 15237121,%d0 | MEM[(volatile uint8_t *)15237121B], _39
*| raster.c:5151: if (l7) {
jbpl _?L214 * jpl .L214 |
*| raster.c:5156: l12 = load32_align2((const uint32_t *)&m0[l12 + UINT32_C(8)]);
move.l _m0,d0 * move.l m0,%d0 | m0,
add.l d6,d0 * add.l %d6,%d0 | tmp69,
move.l d0,-(sp) * move.l %d0,-(%sp) |,
jbsr (a3) * jsr (%a3) | tmp82
*| raster.c:5159: if (l11) {
addq.l #4,sp * addq.l #4,%sp |,
cmp.l d3,d0 * cmp.l %d3,%d0 | l0, l12
jbls _?L215 * jls .L215 |
*| raster.c:5162: l15 = load32_align2((const uint32_t *)&m0[l15 + UINT32_C(0)]);
move.l _m0,d0 * move.l m0,%d0 | m0,
add.l d4,d0 * add.l %d4,%d0 | l5,
move.l d0,-(sp) * move.l %d0,-(%sp) |,
jbsr (a3) * jsr (%a3) | tmp82
*| raster.c:28: return __builtin_bswap16(*ptr);
move.l _m0,a1 * move.l m0,%a1 | m0, tmp77
add.l d0,a1 * add.l %d0,%a1 | tmp87, tmp77
*| raster.c:28: return __builtin_bswap16(*ptr);
move.l d3,a0 * move.l %d3,%a0 | l0, l16
add.l d3,a0 * add.l %d3,%a0 | l0, l16
moveq #0,d0 * moveq #0,%d0 | MEM[(const uint16_t *)_14]
move.w (a1,a0.l),d0 * move.w (%a1,%a0.l),%d0 | MEM[(const uint16_t *)_14], MEM[(const uint16_t *)_14]
ror.w #8,d0 * ror.w #8,%d0 |, _47
*| raster.c:189: *(volatile uint16_t*)adr = value;
move.w d0,15204376 * move.w %d0,15204376 | _47, MEM[(volatile uint16_t *)15204376B]
*| raster.c:5172: if (l15) {
subq.l #1,d5 * subq.l #1,%d5 |, ivtmp_17
addq.l #4,sp * addq.l #4,%sp |,
jbeq _?L221 * jeq .L221 |
_?L216: *.L216:
*| raster.c:184: return *(volatile uint8_t*)adr;
move.b 15237121,d0 * move.b 15237121,%d0 | MEM[(volatile uint8_t *)15237121B], _48
*| raster.c:5185: if (l19) {
jbmi _?L216 * jmi .L216 |
It seems that two processes doing almost the same thing are being generated. In this code, an anonymous function is used to represent the interrupt disabled period in the Rust code as follows:
x68k_disable_intrupt(&mut ||{
let mut y_posizion = 0;
let mut phase = offset;
loop {
while (x68k_bpeek(X68K_MMIO_GPIP) & X68K_MMIO_GPIP_BIT_HSYNC) == 0x00 {
}
Anonymous functions are treated as closures. In this process, the anonymous function passed to x68k_disable_intrupt is passed to the function as a closure that implements the Fn trait. The implementation of the call function for the Fn trait reflects the logic within the anonymous function.
The definition of the Fn trait is such that it inherits from FnMut and FnOnce. Because of this, it seems the anonymous function's logic is also generated within the Wasm as implementations of these traits.
The function I thought I had improved earlier by inlining the load16_align1 function seems to be the implementation of these FnOnce and similar traits. Since load32_align2 appears to be used in the function actually being executed, I will inline this one as well.
static uint32_t load32_align2(const uint32_t *ptr) {
// uint32_t val;
// rmemcpy(&val, ptr, sizeof(val));
// return val;
return __builtin_bswap32(*ptr);
}
The assembly code for the relevant part after the change is as follows.
The byte-swap code has quite a strong presence, but we can expect an improvement compared to before.
*| raster.c:44: return __builtin_bswap32(*ptr);
move.l 8(a0,d2.l),d1 * move.l 8(%a0,%d2.l),%d1 | MEM[(const uint32_t *)_10], _37
ror.w #8,d1 * ror.w #8,%d1 |, _37
swap d1 * swap %d1 | _37
ror.w #8,d1 * ror.w #8,%d1 |, _37
Let's run it.
4 cycles are now being produced.

Although 4 cycles are now being produced, looking closely, the first few rasters are not being scrolled.
Improving the Interrupt Disabled Section
Judging from the situation, it seems that there is some lagging when entering the interrupt disabled section after detecting the end of the vertical blanking period, causing a few rasters to be missed.
Until now, the function that disables interrupts and calls back an anonymous function was implemented as an external C function. However, since we can't expect much more optimization with this approach, I'll adopt a strategy of implementing the anonymous function callback itself in Rust to let the Rust compiler optimize it.
I will implement x68k_disable_intrupt on the Rust side as follows.
fn x68k_disable_intrupt(f: &dyn Fn()) {
x68k_disable_interrupt();
f();
x68k_enable_interrupt();
}
Disabling and enabling interrupts themselves will be implemented using C inline assembly.
// void __wbg_x68kdisableinterrupt_df22111bc28c2e8a(void);
static inline void __wbg_x68kdisableinterrupt_df22111bc28c2e8a(void) {
asm volatile(
"ori.w #$0700, sr\n"
);
}
// void __wbg_x68kenableinterrupt_97c45efdb434642c(void);
static inline void __wbg_x68kenableinterrupt_97c45efdb434642c(void) {
asm volatile(
"andi.w #$f8ff, sr\n"
);
}
With this change, the assembly code from disabling interrupts to starting horizontal sync polling is now generated as follows.
Looking good.
* APP ON (APP) asm_mode=has *#APP
*| 190 "raster.c" 1
ori.w #$0700,sr * ori.w #$0700, sr
*
*| 0 "" 2
*| raster.c:5021: l17 = load32_align2((const uint32_t *)&m0[l17 + UINT32_C(8)]);
* APP OFF (NO_APP) asm_mode=gas *#NO_APP
move.l _m0,a2 * move.l m0,%a2 | m0, m0.851_3
*| raster.c:5021: l17 = load32_align2((const uint32_t *)&m0[l17 + UINT32_C(8)]);
lea (a2,d2.l),a3 * lea (%a2,%d2.l),%a3 | m0.851_3, tmp97, _5
*| raster.c:5027: l20 = load32_align2((const uint32_t *)&m0[l20 + UINT32_C(0)]);
lea (a2,d1.l),a4 * lea (%a2,%d1.l),%a4 | m0.851_3, _72, _7
*| raster.c:5009: goto l12;
move.w #256,a0 * move.w #256,%a0 |, ivtmp_40
*| raster.c:5006: l0 = l8;
moveq #0,d3 * moveq #0,%d3 | l0
_?L205: *.L205:
*| raster.c:180: return *(volatile uint8_t*)adr;
move.b 15237121,d0 * move.b 15237121,%d0 | MEM[(volatile uint8_t *)15237121B], _79
*| raster.c:5016: if (l13) {
jbpl _?L205 * jpl .L205 |
Let's run it.

The problem with the beginning has been resolved, but now a mysterious section appears after outputting 4 cycles.
Horizontal Sync Count in 256-line Display
Actually, even in 256-line display, in high-resolution mode, horizontal sync occurs 512 times per screen.
Until now, taking advantage of the slow internal processing, horizontal sync was only taken once every time the scroll register was changed. Ideally, the process should change the scroll register once every two horizontal syncs.
The non-scrolling section at the bottom of the screen seen earlier is a section that has become apparent now that a certain level of performance has been achieved.
However, it seems we still need to increase the processing speed further to achieve the intended processing.
From here on, it is necessary to improve the Rust processing.
The final Rust code is as follows[3].
mod utils;
use core::f32::consts::PI;
use wasm_bindgen::prelude::*;
const X68K_MMIO_CRTC_GRAPH_X0: u32 = 0xE80018;
const X68K_MMIO_GPIP: u32 = 0xE88001;
const X68K_MMIO_GPIP_BIT_HSYNC: u8 = 0x80;
const X68K_MMIO_GPIP_BIT_VDISP: u8 = 0x10;
#[wasm_bindgen]
extern "C" {
fn x68k_sv(f: &dyn Fn());
fn x68k_sv_mut(f: &mut dyn FnMut());
fn x68k_disable_interrupt();
fn x68k_enable_interrupt();
fn x68k_bpoke(address:u32, value: u8);
fn x68k_wpoke(address:u32, value: u16);
fn x68k_lpoke(address:u32, value: u32);
fn x68k_bpeek(address:u32) -> u8;
fn x68k_wpeek(address:u32) -> u16;
fn x68k_lpeek(address:u32) -> u32;
fn puts(s: &str);
}
fn x68k_disable_intrupt(f: &dyn Fn()) {
x68k_disable_interrupt();
f();
x68k_enable_interrupt();
}
fn build_sin_table() -> Vec<u8> {
let mut sin_table = Vec::new();
for i in 0..64 {
unsafe {
sin_table.push((16.0 + 16.0 * ((i as f32) / 32.0 * PI).sin()).round().to_int_unchecked());
}
}
for _i in 0..4 {
for j in 0..64 {
sin_table.push(sin_table[j]);
}
}
sin_table
}
#[wasm_bindgen]
pub fn main() {
let wave_table = build_sin_table();
x68k_sv(&||{
let mut offset: u16 = 0;
loop {
let wave_table_slice = &wave_table[(offset as usize)..(offset+256) as usize];
while (x68k_bpeek(X68K_MMIO_GPIP) & X68K_MMIO_GPIP_BIT_VDISP) != 0x00 {
}
x68k_disable_intrupt(&||{
while (x68k_bpeek(X68K_MMIO_GPIP) & X68K_MMIO_GPIP_BIT_VDISP) == 0x00 {
}
for x_posizion in wave_table_slice.iter() {
while (x68k_bpeek(X68K_MMIO_GPIP) & X68K_MMIO_GPIP_BIT_HSYNC) == 0x00 {
}
while (x68k_bpeek(X68K_MMIO_GPIP) & X68K_MMIO_GPIP_BIT_HSYNC) != 0x00 {
}
while (x68k_bpeek(X68K_MMIO_GPIP) & X68K_MMIO_GPIP_BIT_HSYNC) == 0x00 {
}
x68k_wpoke(X68K_MMIO_CRTC_GRAPH_X0, *x_posizion as u16);
while (x68k_bpeek(X68K_MMIO_GPIP) & X68K_MMIO_GPIP_BIT_HSYNC) != 0x00 {
}
}
});
// offset = (offset + 1) & 0x3F;
}
});
}
Running this results in a fairly stable output of 4 cycles.

However, there is jitter during display. It seems horizontal sync is occasionally missed.
Countermeasures for Horizontal Sync Misses
Currently, the horizontal sync polling process is a combination of the move.b instruction with 32-bit absolute addressing and the bcc instruction.
_?L224: *.L224:
*| raster.c:184: return *(volatile uint8_t*)adr;
move.b 15237121,d0 * move.b 15237121,%d0 | MEM[(volatile uint8_t *)15237121B], _45
*| raster.c:5002: if (l7) {
jbpl _?L224 * jpl .L224 |
On the other hand, in the MicroPython sample code[4] used as a reference, this loop is a combination of the move.b instruction with address register indirect addressing and the bcc instruction.
Looking at the M68000 manual[5], move.b with 32-bit absolute addressing takes 16 clocks, and the branch with the bcc instruction takes 10 clocks, so the current loop polls in 26 clocks. If it's move.b with address register indirect addressing, it can be executed in 8 clocks, which means the MicroPython sample can poll in 18 clocks.
Since I want to use the address register indirect addressing mode somehow, I rewrote the peek function as follows. To pass the variable adr with an address register constraint, I specified the "a" constraint[6].
static inline uint8_t __wbg_x68kbpeek_a51a25fcf8ed5e80(uint32_t adr) {
uint8_t output;
asm volatile(
"move.b (%1), %0\n"
:"=r"(output):"a"(adr)
:
);
return output;
}
The resulting assembly code is as follows.
It's now using address register indirect addressing, but a tst.b instruction has been added.
This instruction takes 4 clocks, so as a result, the loop can now poll in 22 clocks. (This assumes a zero-wait clock count.)
_?L11: *.L11:
* APP ON (APP) asm_mode=has *#APP
*| 204 "raster.c" 1
move.b (a1),d2 * move.b (%a1), %d2 | tmp63, output
*
*| 0 "" 2
*| raster.c:5117: if (l11) {
* APP OFF (NO_APP) asm_mode=gas *#NO_APP
tst.b d2 * tst.b %d2 | output
jbge _?L11 * jge .L11 |
Although it's only a reduction of 4 clocks, as far as I can confirm with XM6, the jitter has disappeared.
Also, it was confirmed that a 4-cycle raster scroll is possible on the X68000Z, but unfortunately, the display currently breaks down periodically. The cause of this has not been analyzed yet.
Modifying wasm2c
I have modified wasm2c so that the content described so far is reflected in the C source code output during conversion. The following commit shows the details of the modification.
Summary
In this article, I challenged myself with performance tuning the raster scroll code prepared last time.
As a result, I think I was able to achieve a reasonably satisfactory speed.
Regarding the inlining of IO access and the improvement of linear memory access, I have also reflected these in wasm2c. When accessing IO using the same method as this time, code with the fixes in this article will be generated.
Next Time
Next time, I would like to enable IOCS calls and DOS calls to increase what can be done from Rust.
-
Executing Rust and Zig code on a retro PC (X68000) via WebAssembly ↩︎
-
3-minute cooking style ↩︎
-
MicroPython for X680x0 sample code ↩︎
Discussion