2
\$\begingroup\$

I recently made a program with C++ and ASM. Can anyone help me make this code a more efficient one, in the ASM part or both.
I would really appreciate it because I don't know every ASM instruction and probably I am using way too many. BTW the program sums two integer vectors with any size.
The code that I have:

C++:

int i;

extern "C" {
    int add_vtr_asm(int*, int*, int*, int);
}

void add_vtr() {

    __declspec(align(16))
        int vetor1[1024];
    __declspec(align(16))
        int vetor2[1024];
    __declspec(align(16))
        int soma[1024];
    for (i = 0; i <= 1023; i++) {
        vetor1[i] = i;
        vetor2[i] = i;
    }
    add_vtr_asm(vetor1, vetor2, soma, 1024);
    for (i = 0; i <= 1023; i++) {
        printf("% d + % d = % d \n",vetor1[i] ,vetor2[i], soma[i]);
     
    }
    exit(0);
}

int main()
{

    printf("Programa para somar vetores de inteiros: \n");
    printf("Soma de vetores com % d elementos \n", 1024);
    add_vtr();
}

ASM:

.MODEL FLAT, C  

.CODE             
add_vtr_asm PROC 
    push ebp 
    mov ebp,esp
    push esi 
    push edi 
    mov esi,[ebp+8] 
    mov ebx, [ebp+12]
    mov edi, [ebp+16]
    mov ecx,[ebp+20]
    shr ecx,2  
    next:movdqa XMM0,[esi]
    add esi,16
    paddd xmm0,[ebx]
    add ebx,16
    movdqa [edi],xmm0
    add edi,16
    dec ecx
    jnz next
    pop edi
    pop esi
    pop ebp
    ret
    add_vtr_asm ENDP
    END
\$\endgroup\$
0

1 Answer 1

5
\$\begingroup\$

A bug

Using ebx is OK, but like esi and edi it is a callee-save register, so you should save and restore it. It doesn't necessarily cause a problem if you don't, but it can.

Any size?

the program sums two integer vectors with any size

Maybe it does that, but I would say it sums integer arrays that have a length that is a multiple of 4. Since the loads and stores are aligned, it cannot crash if the length is not a multiple of 4 (an aligned load that contains at least one valid byte will succeed, because it cannot cross a page boundary), but in general there may be some other data directly after the array. It's not very nice to overwrite that data. You could use a basic scalar loop to do the last couple of additions, or the "step back from the end of the array" trick (which doesn't apply to arrays smaller than 16 bytes, but that's a much less stringent requirement than requiring the array length to be a multiple of 4).

Making it faster

You've done a decent job with this code, but there is one issue with it: there is too much scalar code in the loop. The vector instructions obviously have to be there, they do the actual work, no way around that, but the number of scalar instructions can be reduced.

There are some tricks to reduce the scalar arithmetic, the biggest one is using complex addressing. Rather than adding 16 to three separate pointers, add 16 to just one dedicated "offset" register, and use addressing like [esi + edx] to add that offset to each of the pointers for "free". It's not really free, but it helps reduce instructions, and that's the main thing for now, but I'll get back to that.

Then you can also use that "offset" to detect when the loop should end, saving another addition, although that introduces a cmp so that may seem useless.. But the next trick is: rather than using a separate cmp to test whether the offset has reached the end, you can start all the pointers at the end of the arrays, and use a negative offset that counts up towards zero. Then the add ecx, 16 has the comparison built in: you can use jnz next straight after that without a cmp.

By now the loop looks something like this:

next:
    movdqa xmm0,[esi + ecx]
    paddd xmm0,[ebx + ecx]
    movdqa [edi + ecx],xmm0
    add ecx, 16
    jnz next

A lot fewer instructions to be sure.

But is it good? On for example Intel Rocket Lake, yes. Almost twice as good as the loop we started with, according to uiCA, but I haven't tried it and simulations don't always match reality. But various other Intel processors have a quirk that makes this not quite ideal: some processors had 3 address generation units that were not all equally capable, two "full" AGUs on ports 2 and 3, but a "simple AGU" on port 7 (added in Haswell). "Simple" in this context means that it cannot deal with complex addresses, such as edi + ecx. On those processors, the three address calculations are therefore spread over only 2 AGUs, forcing the loop to run no faster than 2 iterations every 3 cycles.

For those processors, but not Rocket Lake, it would be beneficial to reintroduce one of the adds, so that the store can use a simple address:

next:
    movdqa xmm0,[esi + ecx]
    paddd xmm0,[ebx + ecx]
    movdqa [edi],xmm0
    add edi, 16
    add ecx, 16
    jnz next

As is often the case at this level of optimization, the version that you should use depends on which processor is most important to you, or you could use both versions and dynamically choose between based on CPUID.

I have conveniently left out the required "set-up" that these loops need, which is a bit more complicated than what you original loop needed, but the extra time spent there is not so relevant compared to the time saved in the loop.

The alignment of the loop may be somewhat important, though that should primarily affect processors that have had their loop stream detector disabled due to errata (Skylake, Kaby Lake) and therefore would run the loop from their µop cache (which is sensitive to alignment). Anyway, you could use the ALIGN directive before the loop to align it, the padding would take the form of a nop.

Other ways to make it faster are using AVX2 or AVX512, for wider vectors.

Unrolling by a small factor may help on processors such as Haswell or Skylake, which would be front-end limited at ~1.33 cycles per iteration with that second loop (and AGU limited at ~1.5 cycles per iteration by the first loop). Unrolling by a factor of 2 would reduce the number of instructions just enough to move the bottleneck to ports 2 and 3 on Skylake, just below 1.2 cycles per iteration (2.3 cycles per two iterations of the original loop), a bit more than 1 because the store has an unfortunate habit of occasionally "stealing" ports 2 or 3 for its AGU µop, rather than it always going to port 7. Port 7 goes slightly underutilized. That sort of behaviour is unfortunately typical for those processors. BTW these numbers are all uiCA estimates, not tried on real hardware.

\$\endgroup\$
2
  • \$\begingroup\$ If you were fine-tuning the algorithm for those specific cores, wouldn’t you use the widest vector instructions the target supports? \$\endgroup\$
    – Davislor
    May 15 at 0:57
  • \$\begingroup\$ @Davislor often yes, but it depends on what you want from the code. Fine tuning SSE2 code for new processors also makes sense if you want just one piece of code that is compatible with lots of processors, but fast-within-that-constraint for processors that the code actually runs on in practice \$\endgroup\$
    – harold
    May 15 at 2:59

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.