`ui_test` but for asm

Heya folks,

I'm wondering if any of you know of a test library that lets me write assertions against compiled assembly. Something similar to ui_test, but against the compiled artifact rather than the compiler output, and with custom assertions rather than comparing for equality.

I want to make sure that certain operations are free of error handling paths. Doing this as a one-off with cargo-show-asm is useful, but not useful enough in the era of AI code generation. I need to be able to recheck a lot of implementation variants repeatedly and automatically.

As an aside, if you see a different solution to solve the same problem I'm all ears too!

I don't think this is a practical solution even in theory. How do you determine what an "error handling path" looks like? How do you handle the near-infinite variety of assembly output given an optimizing compiler?

I know this is an unpopular opinion for many, but... if you're not able to trust the AI to write good enough code for your purposes, maybe don't use the AI? If you insist though, why not also use the AI to validate the assembly?

2 Likes

Let's leave AI out of the discussion, it is irrelevant. I hoped it came across as slightly funny but I now see that it is a distracting remark, my mistake.

An example of what I hope to detect:

const SIDE_U8: u8 = 32;
const SIDE_U16: u16 = SIDE_U8 as u16;
const SIDE_POW2_U16: u16 = SIDE_U16 * SIDE_U16;
const SIDE_POW3_U16: u16 = SIDE_POW2_U16 * SIDE_U16;

#[derive(Debug, Copy, Clone)]
pub struct Index3([u8; 3]);

impl Index3 {
    #[inline]
    pub fn new(indices: [u8; 3]) -> Option<Self> {
        if indices.iter().copied().all(|i| i < SIDE_U8) {
            Some(Self(indices))
        }
        else {
            None
        }
    }

    #[inline]
    pub fn pack(self) -> Index {
        let strides = [1, SIDE_U16, SIDE_POW2_U16];
        Index::new(core::iter::zip(self.0.map(|i| i as u16), strides).map(|(i, s)| i * s).sum()).unwrap()
    }
}

#[derive(Debug, Copy, Clone)]
pub struct Index(u16);

impl Index {
    #[inline]
    pub fn new(packed: u16) -> Option<Self> {
        if packed < SIDE_POW3_U16 {
            Some(Self(packed))
        } else {
            None
        }
    }

    #[inline]
    pub fn into_raw(self) -> u16 {
        self.0
    }
}


#[doc(hidden)]
#[inline(never)]
pub fn __cargo_asm_pack_an_index(index: Index3) -> Index {
    index.pack()
}

The output of cargo asm on my machine for __cargo_asm_pack_an_index is:

.section .text.ex::__cargo_asm_pack_an_index,"ax",@progbits
        .globl  ex::__cargo_asm_pack_an_index
        .p2align        4
.type   ex::__cargo_asm_pack_an_index,@function
ex::__cargo_asm_pack_an_index:
        .cfi_startproc
        movzx eax, dil
        mov ecx, edi
        shr ecx, 3
        and ecx, 8160
        add ecx, eax
        shr edi, 6
        and edi, 64512
        add di, cx
        js .LBB0_2
        mov eax, edi
        ret
.LBB0_2:
        push rax
        .cfi_def_cfa_offset 16
        lea rdi, [rip + .Lanon.1b1a26bb320d9f62de4ece743ed9d8a5.1]
        call qword ptr [rip + core::option::unwrap_failed@GOTPCREL]

The symbol core::option::unwrap_failed tells me that the compiler does not (unremarkebly) recognize that the Index3 type ensures that the individual indices are less than the extent of 32, and that therefore the packed Index::new should never return None in Index3::pack.

Please be reminded that I'm not asking how to get it to compile without an error handling path that shouldn't be here, I am asking how I can create automated tests that make this type of assertion.

1 Like

Are you looking for no_panic - Rust then?

2 Likes

That could be made to work, combining ui_test or trybuild with no_panic. It is not as flexible as what I had in mind but maybe it's sufficient.

EDIT: The no_panic output contains machine-dependent text, and so the .stderr snapshots aren't stable.

The code I'm using to experiment is now published at GitHub - mickvangelderen/low-level-assert-lab · GitHub.

It currently implements tests based with escargot and no-panic. I've found that:

  1. tests must build the test binary twice
  2. you have to actually call the function in the test binary

The reason for 1. is because tests need to verify that the build fails when optimizations are disabled so you're sure that the code under test is actually being emitted in the binary, and they need to verify that with optimizations enabled, the compiler is actually able to eliminate all panics.

The reason for 2. is that the compiler probably eliminates the function entirely. As a consequence you have to construct input arguments and maybe sprinkles some black_box around.

It works though!

It's not really necessary to construct inputs; this is sufficient:

fn no_run<A, R>(in_fn: impl Fn(A) -> R) {
    let callee: &dyn Fn(A) -> R = &in_fn;
    std::hint::black_box(callee);
}

no_run(your_fn);
no_run(YourStruct::some_method);

(replace with &mut dyn FnMut(A) -> R or Box<dyn FnOnce(A) -> R> as appropriate).

Playground link: Rust Playground

1 Like