C Sortierverfahren in Assembler optimieren

kali-hi

Banned
Registriert
Sep. 2025
Beiträge
760
Nabend, ich nehme noch einmal Bezug auf: https://www.computerbase.de/forum/threads/assemblerprogramm-erklaeren.2259161/ und wage einen zweiten Anlauf... Ich würde mir konstruktive Beiträge wünschen, kein Geflame, und ChatGpt-Antwort nur, wenn sie auch einen Mehrwert böten.

Das Sortierverfahren sieht in C so aus:

C:
#include <stdio.h>

int arr[] = {3, 5, 4, 1, 9, 7, 2, 6, 8};
int n = 9;
char str[] = "%d\n";

int iteration() {
  int i = 0, a, b;
  while (i < n - 1) {
    a = arr[i];
    b = arr[i + 1];
    if (a > b) {
      arr[i] = b;
      arr[i + 1] = a;
      return 1;
    }
    i++;
  }
  return 0;
}

void print() {
  int i = 0;
  while (i < n) {
    printf(str, arr[i]);
    i++;
  }
}

int main() {
  while (iteration())
    ;
  print();
  return 0;
}

An sich recht simple, sortiere so lange, bis es keine Änderung mehr gab, und gib dann alle Zahlen aus (das Verfahren ist jetzt auch stabil).

Mit gcc -S sort.c wird daraus:

Code:
    .file    "sort.c"
    .text
    .globl    arr
    .data
    .align 32
    .type    arr, @object
    .size    arr, 36
arr:
    .long    3
    .long    5
    .long    4
    .long    1
    .long    9
    .long    7
    .long    2
    .long    6
    .long    8
    .globl    n
    .align 4
    .type    n, @object
    .size    n, 4
n:
    .long    9
    .globl    str
    .type    str, @object
    .size    str, 4
str:
    .string    "%d\n"
    .text
    .globl    iteration
    .type    iteration, @function
iteration:
.LFB0:
    .cfi_startproc
    pushq    %rbp
    .cfi_def_cfa_offset 16
    .cfi_offset 6, -16
    movq    %rsp, %rbp
    .cfi_def_cfa_register 6
    movl    $0, -4(%rbp)
    jmp    .L2
.L5:
    movl    -4(%rbp), %eax
    cltq
    leaq    0(,%rax,4), %rdx
    leaq    arr(%rip), %rax
    movl    (%rdx,%rax), %eax
    movl    %eax, -8(%rbp)
    movl    -4(%rbp), %eax
    addl    $1, %eax
    cltq
    leaq    0(,%rax,4), %rdx
    leaq    arr(%rip), %rax
    movl    (%rdx,%rax), %eax
    movl    %eax, -12(%rbp)
    movl    -8(%rbp), %eax
    cmpl    -12(%rbp), %eax
    jle    .L3
    movl    -4(%rbp), %eax
    cltq
    leaq    0(,%rax,4), %rcx
    leaq    arr(%rip), %rdx
    movl    -12(%rbp), %eax
    movl    %eax, (%rcx,%rdx)
    movl    -4(%rbp), %eax
    addl    $1, %eax
    cltq
    leaq    0(,%rax,4), %rcx
    leaq    arr(%rip), %rdx
    movl    -8(%rbp), %eax
    movl    %eax, (%rcx,%rdx)
    movl    $1, %eax
    jmp    .L4
.L3:
    addl    $1, -4(%rbp)
.L2:
    movl    n(%rip), %eax
    subl    $1, %eax
    cmpl    %eax, -4(%rbp)
    jl    .L5
    movl    $0, %eax
.L4:
    popq    %rbp
    .cfi_def_cfa 7, 8
    ret
    .cfi_endproc
.LFE0:
    .size    iteration, .-iteration
    .globl    print
    .type    print, @function
print:
.LFB1:
    .cfi_startproc
    pushq    %rbp
    .cfi_def_cfa_offset 16
    .cfi_offset 6, -16
    movq    %rsp, %rbp
    .cfi_def_cfa_register 6
    subq    $16, %rsp
    movl    $0, -4(%rbp)
    jmp    .L7
.L8:
    movl    -4(%rbp), %eax
    cltq
    leaq    0(,%rax,4), %rdx
    leaq    arr(%rip), %rax
    movl    (%rdx,%rax), %eax
    movl    %eax, %esi
    leaq    str(%rip), %rax
    movq    %rax, %rdi
    movl    $0, %eax
    call    printf@PLT
    addl    $1, -4(%rbp)
.L7:
    movl    n(%rip), %eax
    cmpl    %eax, -4(%rbp)
    jl    .L8
    nop
    nop
    leave
    .cfi_def_cfa 7, 8
    ret
    .cfi_endproc
.LFE1:
    .size    print, .-print
    .globl    main
    .type    main, @function
main:
.LFB2:
    .cfi_startproc
    pushq    %rbp
    .cfi_def_cfa_offset 16
    .cfi_offset 6, -16
    movq    %rsp, %rbp
    .cfi_def_cfa_register 6
    nop
.L10:
    movl    $0, %eax
    call    iteration
    testl    %eax, %eax
    jne    .L10
    movl    $0, %eax
    call    print
    movl    $0, %eax
    popq    %rbp
    .cfi_def_cfa 7, 8
    ret
    .cfi_endproc
.LFE2:
    .size    main, .-main
    .ident    "GCC: (Debian 14.2.0-19) 14.2.0"
    .section    .note.GNU-stack,"",@progbits

also 152 Zeilen reinster Maschinencode, der wie Kraut und Rüben aussieht. Er kann ausgeführt werden mit gcc sort.s -o sort und ./sort.

Diesen Assemblercode möchte ich jetzt aber lesbarer machen. Das heißt, folgende Kriterien sind wichtig:

1. Übersichtlichkeit und Verständlichkeit,
2. weniger Sprungmarken,
3. weniger Zeilen,
4. höhere Laufzeiteffizienz

Mein Ergebnis ist bislang folgendes:

Code:
    .file    "sort.c"
    .text
    .globl    arr

    .data
    .align 32
    .type    arr, @object
    .size    arr, 36
arr:
    .long    3
    .long    5
    .long    4
    .long    1
    .long    9
    .long    7
    .long    2
    .long    6
    .long    8
    .globl    n
    .align 4
    .type    n, @object
    .size    n, 4
n:
    .long    9
    .globl    str
    .type    str, @object
    .size    str, 4
str:
    .string    "%d\n"

    .text
    .globl    iteration
    .type    iteration, @function
iteration:
.LFB0:
    .cfi_startproc
    pushq    %rbp
    .cfi_def_cfa_offset 16
    .cfi_offset 6, -16
    movq    %rsp, %rbp
    .cfi_def_cfa_register 6
    movl    $-1, -4(%rbp)
.L1:
    movl    n(%rip), %eax
    subl    $1, %eax
    addl    $1, -4(%rbp)
    cmpl    %eax, -4(%rbp)
    jge    .L2
    movl    -4(%rbp), %eax
    cltq
    leaq    0(,%rax,4), %rdx
    leaq    arr(%rip), %rax
    movl    (%rdx,%rax), %eax
    movl    %eax, -8(%rbp)
    movl    -4(%rbp), %eax
    addl    $1, %eax
    cltq
    leaq    0(,%rax,4), %rdx
    leaq    arr(%rip), %rax
    movl    (%rdx,%rax), %eax
    movl    %eax, -12(%rbp)
    movl    -8(%rbp), %eax
    cmpl    -12(%rbp), %eax
    jle    .L1
    movl    -4(%rbp), %eax
    cltq
    leaq    0(,%rax,4), %rcx
    leaq    arr(%rip), %rdx
    movl    -12(%rbp), %eax
    movl    %eax, (%rcx,%rdx)
    movl    -4(%rbp), %eax
    addl    $1, %eax
    cltq
    leaq    0(,%rax,4), %rcx
    leaq    arr(%rip), %rdx
    movl    -8(%rbp), %eax
    movl    %eax, (%rcx,%rdx)
    movl    $1, %eax
    jmp    .L3
.L2:
    movl    $0, %eax
.L3:
    popq    %rbp
    ret
    .cfi_endproc
    .size    iteration, .-iteration

    .globl    print
    .type    print, @function
print:
    .cfi_startproc
    pushq    %rbp
    .cfi_def_cfa_offset 16
    .cfi_offset 6, -16
    movq    %rsp, %rbp
    .cfi_def_cfa_register 6
    subq    $16, %rsp
    movl    $0, -4(%rbp)
.L4:
    movl    n(%rip), %eax
    cmpl    %eax, -4(%rbp)
    jge    .L5
    movl    -4(%rbp), %eax
    cltq
    leaq    0(,%rax,4), %rdx
    leaq    arr(%rip), %rax
    movl    (%rdx,%rax), %eax
    movl    %eax, %esi
    leaq    str(%rip), %rax
    movq    %rax, %rdi
    movl    $0, %eax
    call    printf@PLT
    addl    $1, -4(%rbp)
    jmp    .L4
.L5:
    leave
    ret
    .cfi_endproc
    .size    print, .-print

    .globl    main
    .type    main, @function
main:
    .cfi_startproc
    pushq    %rbp
    .cfi_def_cfa_offset 16
    .cfi_offset 6, -16
    movq    %rsp, %rbp
    .cfi_def_cfa_register 6
.L6:
    movl    $0, %eax
    call    iteration
    testl    %eax, %eax
    jne    .L6
    movl    $0, %eax
    call    print
    movl    $0, %eax
    popq    %rbp
    ret
    .cfi_endproc
    .size    main, .-main
    .ident    "GCC: (Debian 14.2.0-19) 14.2.0"
    .section    .note.GNU-stack,"",@progbits

Ich konnte also durch Umstellen ~ 10 Zeilen und einige Sprungmarken sparen.

Meine Frage wäre nun, ob man hier noch weiter optimieren kann, bzw. oder ob ich noch etwas übersehen habe.
 
Warum? was ist das Ziel? Die Hochsprachen wie C wurde nur wegen der Lesabrkeit erfunden und die Compiler erzeugen in den allermeisten Fällen schnelleren Maschinencode - die sind sehr gut darin.
Fokus liegt das nur auf Effizienz und nicht Lesbarkeit - warum auch? ClaudeSonet 4.5 is aber brauchbar:

; Bubble Sort Implementation in x86-64 Assembly
; NASM syntax for Linux x86-64
; This implements the same bubble sort algorithm as the C code

section .data
; Array to sort
arr: dd 3, 5, 4, 1, 9, 7, 2, 6, 8
n: dd 9
str: db "%d", 10, 0 ; Format string "%d\n"

section .text
global main
extern printf

; ============================================================================
; iteration() - Performs one pass of bubble sort
; Returns: 1 if a swap was made, 0 if no swaps (array is sorted)
; ============================================================================
iteration:
push rbp
mov rbp, rsp

xor rcx, rcx ; i = 0
mov r8d, dword [n] ; Load n
dec r8d ; r8d = n - 1

.loop:
cmp rcx, r8 ; Compare i with n-1
jge .no_swap ; if i >= n-1, exit loop

; Load arr and arr[i+1]
mov eax, dword [arr + rcx*4] ; a = arr
mov edx, dword [arr + rcx*4 + 4]; b = arr[i+1]

; Check if swap needed
cmp eax, edx ; Compare a with b
jle .continue ; if a <= b, no swap needed

; Perform swap: arr = b, arr[i+1] = a
mov dword [arr + rcx*4], edx ; arr = b
mov dword [arr + rcx*4 + 4], eax; arr[i+1] = a

; Return 1 (swap was made)
mov eax, 1
pop rbp
ret

.continue:
inc rcx ; i++
jmp .loop

.no_swap:
xor eax, eax ; Return 0 (no swap made)
pop rbp
ret

; ============================================================================
; print() - Prints all elements of the array
; ============================================================================
print:
push rbp
mov rbp, rsp
push rbx ; Save rbx (callee-saved)
push r12 ; Save r12 (callee-saved)

xor rbx, rbx ; i = 0
mov r12d, dword [n] ; Load n into r12

.loop:
cmp rbx, r12 ; Compare i with n
jge .done ; if i >= n, exit loop

; Prepare printf call
lea rdi, [str] ; 1st arg: format string
mov esi, dword [arr + rbx*4] ; 2nd arg: arr
xor eax, eax ; No vector registers used

; Save rbx before printf (it might be clobbered)
push rbx
call printf
pop rbx

inc rbx ; i++
jmp .loop

.done:
pop r12
pop rbx
pop rbp
ret

; ============================================================================
; main() - Entry point
; ============================================================================
main:
push rbp
mov rbp, rsp

.sort_loop:
call iteration ; Call iteration()
test eax, eax ; Check return value
jnz .sort_loop ; if returned 1, continue sorting

call print ; Print sorted array

xor eax, eax ; Return 0
pop rbp
ret
 
  • Gefällt mir
Reaktionen: kali-hi
kali-hi schrieb:
4. höhere Laufzeiteffizienz
weniger Zeilen sind oftmals keine höhere Effizienz und Lesbarkeit ist dafür schon gar kein Kriterium.
 
  • Gefällt mir
Reaktionen: NJay, tollertyp und Bob.Sponge
TorenAltair schrieb:
weniger Zeilen sind oftmals keine höhere Effizienz
Das wage ich mal anzuzweifeln, dass weniger Zeilen, Vergleiche und Sprungmarken nicht zu schnellerem Code führen. Aber du kannst das bestimmt etwas begründen
Ergänzung ()

dermoritz schrieb:
NASM syntax for Linux x86-64
Nicht NASM... ich wollte Assembly haben, den gcc versteht
 
  • Gefällt mir
Reaktionen: dms, dermoritz und kali-hi
@kali-hi Du brauchst nicht anzuzweifeln, dass weniger Zeilen Assemblercode oftmals eben nicht zu schnellerem Code führen. @TorenAltair hat nicht immer gesagt.

gcc (Version 13.4.0) erzeugt mir jedenfalls für den C-Code aus #1 mit -O3 in etwa doppelt so viele Zeilen Assemblercode wie mit -O0 (default.) Übrigens ist bei mir -O1 am kürzesten (107 Zeilen), während -O3 und -Ofast den identischen Assemblercode mit 286 Zeilen erzeugen. -O0 und -O2 sind je ca. 140.

Und ich zweifle nicht an, dass -O3 eher schnellere Ausführung als -O0 liefern dürfte. Kann ja aber jeder gerne testen.
 
  • Gefällt mir
Reaktionen: kuddlmuddl, tollertyp und kali-hi
... In Zeile 35 steht noch ein .LFB0: ohne Funktion, das könnte auch weg. Und mir ist noch nicht klar, wofür .size gut sei.
Ergänzung ()

simpsonsfan schrieb:
gcc (Version 13.4.0) erzeugt mir jedenfalls für den C-Code aus #1 mit -O3 in etwa doppelt so viele Zeilen Assemblercode wie mit -O0 (default.) Übrigens ist bei mir -O1 am kürzesten (107 Zeilen), während -O3 und -Ofast den identischen Assemblercode mit 286 Zeilen erzeugen. -O0 und -O2 sind je ca. 140.
Wenn nur -S angegeben wird, wird glaube ich implizit auch -O0 gesetzt
 
kali-hi schrieb:
Aber du kannst das bestimmt etwas begründen
Verschiedene Befehle benötigen eine unterschiedliche Anzahl von Takten.
 
  • Gefällt mir
Reaktionen: kuddlmuddl, BeBur, tollertyp und 3 andere
Bei manchen Mikroprozessoren sind beispielsweise mehrere MOV-Operationen schneller als eine SWP-Operation. Genauso ist der Mikroprozessor relevant. Auf Modell A kann eine Variante schneller sein und bei Modell B umgekehrt. Mit reinem Lesetext ohne auf eine spezifische Architektur zu optimieren, kommt nichts dabei rum.
Übrigens mit ein Grund warum früher Spielekonsolen mit einheitlicher Hardware idR schneller als vergleichbare PCs waren.

Nachtrag: Wenn es Dir darum geht mit (Pseudo-)Assembler-Befehlen zu spielen. Kauf Dir TIS-100 bei Steam oder Co. Dort hat jede Operation 1 Takt.
 
  • Gefällt mir
Reaktionen: NJay, kali-hi und Tornhoof
Takte sind der offensichtliche Maßstab, aber jede Instruktion benutzt so und so viele Ports der CPU. Also Bausteine der CPU, vereinfacht ausgedrückt, solange sind die belegt und eine Instruktion muss drauf warten, dh es ist häufig nützlich Instruktionen um zuordnen oder gar andere zu nutzen nur um das zu umgehen.

Siehe https://www.uops.info/ für mehr Hintergrund
 
  • Gefällt mir
Reaktionen: kali-hi
Würde auch die Länge bis zur nächsten Sprungmarke (also die (x̄) Sprungweite) eine Rolle spielen - oder ist das egal?
 
Short, Near und far jmp sind unterschiedliche Instruktionen mit unterschiedlichen Latenzen. Far jump kann andere Segmente ansprechen.

Near jump mag notwendig sein in zu großen schleifen, also ja es kann einen Unterschied machen, aber selbst short jumps können einiges an Distanz abdecken. Exakte werte habe ich aber nicht mehr im Kopf.
 
  • Gefällt mir
Reaktionen: kali-hi
Ja, es scheint so, als sollten die Sprungweiten (zumindest "kategorisch") auch mit berücksichtigt werden... Habe vorhin folgende Frage dazu gefunden: Link

simpsonsfan schrieb:
Und ich zweifle nicht an, dass -O3 eher schnellere Ausführung als -O0 liefern dürfte. Kann ja aber jeder gerne testen.
Das Problem ist glaube ich auch, dass -O3 erkennen könnte, dass nur eine konstante Liste vorliegt, und dann gar nicht mehr sortiert wird... Sprich, das eigentliche Verfahren nicht mehr optimiert würde... (aber das ist eher nur eine Vermutung, getestet noch nicht)

Also, knapp vorbei ist auch daneben...
 
Also einen ineffizienter C-Algorithmus, von dem man die Assembler-Übersetzung nimmt, soll optimiert werden?
Muss man glaube ich nicht ganz verstehen?
 
@tollertyp Meines Wissen nach wird für kleine Listen/Arrays gerne BubbleSort genommen, da es sehr einfach zu implementieren und für wenige Elemente genau so schnell wie Merge- oder Quick-Sort ist - weil ein konstanter Overhead entfällt. Und hier geht es auch ums Lernen - um einfache Beispiele. Auf A* in Assembler hätte ich jetzt keine Lust...
 
simpsonsfan schrieb:
Und ich zweifle nicht an, dass -O3 eher schnellere Ausführung als -O0 liefern dürfte. Kann ja aber jeder gerne testen.

In diesem Fall zweifle ich das schon an, weil ich schon gesehen habe, dass bei einem anderen Bubble-Sort-Code -O3 ein Resultat geliefert hat, das extrem langsam war. Das Problem liegt darin, dass gcc -O3 (zumindest in der anderen Variante) das Zurueckspeichern von a und b mit einem SIMD-Befehl macht, und das Reinladen des naechsten a und b auch mit einem SIMD-Befehl, und dass sich diese beiden Zugriffe so ueberlappen, dass diverse Hardware-Optimierungen, die normalerweise greifen, nicht mehr greifen, und dass daher das Laden dann viele Zyklen braucht.

@kali-hi: Wie schon jemand anderes erwaehnt hat, kommt bei -O (also -O1) kuerzerer Code heraus. Du kannst auch noch -Os (fuer Groesse optimieren) probieren.
 
  • Gefällt mir
Reaktionen: kali-hi und simpsonsfan
TorenAltair schrieb:
Mit reinem Lesetext ohne auf eine spezifische Architektur zu optimieren, kommt nichts dabei rum.
Rein aus Interesse: Kann ich auch gegen mein konkretes CPU Modell programmieren, also nicht gegen die ISA x64? Geht das vom Prinzip her so wie man auch assembly schreibt?
Ist dann jede Instruktion 1 Takt oder ist da dann immer noch eine Abstraktionsschicht zwischen?
 
  • Gefällt mir
Reaktionen: kali-hi
Da für eine Sprunglänge von (2^n)-1 n*2 Kosten gelten, ist es vermutlich sinnvoll, nur ein paar lange Sprünge anstatt viele kleine zu haben, um die Kosten zu reduzieren.
Ergänzung ()

Sorry, das war Quatsch
 
Zuletzt bearbeitet:
BeBur schrieb:
Rein aus Interesse: Kann ich auch gegen mein konkretes CPU Modell programmieren, also nicht gegen die ISA x64?

Geht mit gcc mit "-march=native".

Geht das vom Prinzip her so wie man auch assembly schreibt?

Klar, es ist assembly language.

Ist dann jede Instruktion 1 Takt oder ist da dann immer noch eine Abstraktionsschicht zwischen?

Aktuelle Prozessoren koennen mehrere Befehle pro Takt ausfuehren (ob jetzt mit -march=native oder nicht), wobei die Latenzen der Befehle zwischen 0 und hunderten Zyklen liegen.

Was "-march=native" macht: Es erlaubt dem Compiler, Befehlssatzerweiterungen zu verwenden (z.B. AVX), was gelegentlich viel bringt. Und es beruecksichtigt die Kosten von Befehlen auf Deinem konkreten CPU-Modell, statt Kosten fuer haeufige CPU-Modelle zugrundezulegen (auch "-mtune=native", wenn man nur letzteres will, der Code aber noch ueberall laufen soll). Das Tuning bringt vielleicht manchmal etwas, grosse Spruenge wuerde ich dabei nicht erwarten.

Wenn Du in assembly language schreibst, liegen sowohl die Verwendung von Befehlssatzerweiterungen als auch das Tuning bei Dir.
 
  • Gefällt mir
Reaktionen: kali-hi

Ähnliche Themen

Antworten
33
Aufrufe
1.642
Zurück
Oben