BlitzMax Interna

Übersicht BlitzMax, BlitzMax NG FAQs und Tutorials

Neue Antwort erstellen

Thunder

Betreff: BlitzMax Interna

BeitragDi, Jul 02, 2013 12:17
Antworten mit Zitat
Benutzer-Profile anzeigen
Hallo,

ich beschäftige mich gerne mit BlitzMax und mit Assembler und ich verbinde auch gerne beides miteinander. Dabei stößt man auf ein paar Dinge, die man aus anderen Sprachen so nicht kennt. Ich habe schon ein Tutorial geschrieben, das praxisorientiert war und sich damit beschäftigt hat, wie man C/C++ oder Assembler mit BlitzMax verbindet (Variablen/Funktionen importieren/exportieren etc). Das hier ist was anderes, ich möchte ein bisschen von dem aufschreiben, das ich über BlitzMax-interne Sachen gelernt habe. Teilweise wird das schon bekannt sein, aber ich finde es interessant, wie alles intern vonstatten geht, und vielleicht hilft es noch jemandem.


Strings "als Primitive"

Beginnen wir Mal mit etwas, das wahrscheinlich viele schon wissen...
Strings sind nie primitive Datentypen, aber sie werden besonders in benutzerfreundlichen Hochsprachen immer öfter stark in die Sprache integriert. Was für einen mathematischen Sinn hat es zum Beispiel, zwei Strings zu addieren? Gar keinen, aber über eine Stringklasse und Operatorenüberladung kann man das Pluszeichen mit der Konkatenation von Strings verbinden. Nur... BlitzMax hat keine Operatorenüberladung. Und eine Stringklasse? Ja, aber die ist versteckt in brl.blitz in blitz_string.c und blitz_string.h.

Dass wir so einfach mit Strings umgehen können (insbesondere Verketten und Slices), kommt daher, dass der BlitzMax-Compiler so nett zu uns ist und das in ihn implementiert ist. Intern wird eine Konkatenation über bbStringConcat abgewickelt, Slices über bbStringSlice. Es wird also der Gebrauch eines Operators (Pluszeichen bzw. eckige Klammern) in Funktionsaufrufe umgewandelt.

Ihr könnt das übrigens ganz einfach selber ausprobieren:
BlitzMax: [AUSKLAPPEN]
s1 = "Hallo "
s2 = "Patrick"
s1 = s1 + s2[3..]

WriteStdout s1+"~n"

Die Zeile s1 = s1 + s2[3..] lässt sich auch so wiedergeben: s1 = bbStringConcat(s1, bbStringSlice(s2, 3, s2.length))
Diese Funktionen sind auch in brl.blitz definiert, ihr müsst sie nur noch als Extern deklarieren:
BlitzMax: [AUSKLAPPEN]
Extern
Function bbStringConcat:String(str1:String, str2:String)
Function bbStringSlice:String(str:String, pos1:Int, pos2:Int)
EndExtern

s1 = "Hallo "
s2 = "Patrick"
s1 = bbStringConcat(s1, bbStringSlice(s2, 3, s2.length))

WriteStdout s1+"~n"


Und genau diese Umwandlung nimmt uns der Compiler ab, wobei der natürlich dann Assemblercode generiert.


Code außerhalb von Funktionen

In BASIC ist das generell verbreitet, aber viele andere Programmiersprachen bieten nicht die Möglichkeit Code außerhalb von Funktionen zu platzieren, weil er ja dann logischerweise nie ausgeführt würde. BASIC fasst den Code außerhalb meistens zu einer main-Funktion zusammen. Das geschieht intern und in BlitzMax nicht nur für das eine Programm selber, das man kompiliert, sondern für jedes Modul.
Jedes Modul hat seine eigene "main-Funktion", die alle aufgerufen werden, bevor das eigentliche Programm startet, das man programmiert hat.

bb_main ist die "Haupt-main-Funktion". Sie besteht immer aus dem Code außerhalb aller Funktionen in der Datei, die man als App (also nicht als Modul) kompiliert hat.
Daneben gibt es beispielsweise __bb_blitz_blitz oder __bb_max2d_max2d. Bei diesen Modul-main-Funktionen funktioniert also die Namensgebung nach dem Namen des Moduls.

Okay... also diese Modul-main-Funktionen werden alle vor bb_main aufgerufen. Aber bestimmte Module können mehrfach importiert worden sein. Zum Beispiel so:
BlitzMax: [AUSKLAPPEN]
Strict
Framework brl.blitz
Import brl.math
Import brl.basic ' brl.basic importiert brl.math


Das heißt, der Compiler müsste darauf achtgeben, dass jede dieser main-Funktionen nur einmal aufgerufen wird. Tut er aber nicht.
Das ganze funktioniert in der Laufzeit des Programms (und das ist wichtig, wenn man den BlitzMax-Assemblercode verstehen will). Jede dieser main-Funktionen merkt sich, ob sie schon Mal ausgeführt worden ist. Wird sie zufällig ein zweites oder drittes oder viertes... Mal aufgerufen, bemerkt sie das selbst über die Variable, die sie auf 1 setzt, wenn sie das erste Mal ausgeführt wird.
Mich hat dieses Codestück überrascht und ich dachte zuerst es sei unnötig:
Code: [AUSKLAPPEN]
___bb_blitz_blitz:
   push   ebp
   mov   ebp,esp
   cmp   dword [_112],0   ; Variable wird auf 0 geprüft
   je   _113   ; wenn sie 0 ist, wird die echte Funktion ausgeführt
   mov   eax,0
   mov   esp,ebp
   pop   ebp
   ret      ; ansonsten kommt ein Return 0
_113:
   ; echte Funktion


dieses Codestück (mit anderen Labels) steht vor jeder main-Funktion.

Das heißt einerseits, es ist nicht ohne weiteres möglich, die main-Funktion nochmal aufzurufen (über eingelinkten Assemblercode wäre das ja sonst kein Problem), und dieser Codeteil darf keinesfalls (z.B. durch einen Optimierer) entfernt werden, denn dann erhält man komische Fehler (z.B. Stack-Overflows) und interessante Mehrfach-Ausgaben.


Besondere "Funktionen"

Wenn man euch fragen würde, ob Min eine Funktion (z.B. Min(4,12)) ist, würdet ihr mit Ja oder Nein antworten?
Es ist nämlich in BlitzMax keine Funktion, aber auch irgendwie schon. BlitzMax sieht einige aus Buchstaben bestehende Literale*, die aber wie Funktionen aufgerufen werden, als "mathematische" Operatoren an. Dazu gehören: Min, Max, Not, Abs, Sgn, SizeOf, VarPtr, Asc, Chr und Len. Ich hoffe, ich hab keinen ausgelassen.
* wusste nicht, wie ich das besser formulieren soll

Wenn ich mir selber eine Min-Funktion schreibe:
BlitzMax: [AUSKLAPPEN]
Function MeinMin:Int(x:Int, y:Int)
If x < y Then Return x Else Return y
EndFunction

Dann kann ich sie genauso verwenden, wie das Min von BlitzMax... und noch mehr. Weil Min als mathematischer Operator angesehen wird, darf es nicht außerhalb von einer Expression stehen. Meine eigene Funktion könnte ich aufrufen und auf den Rückgabewert pfeifen:
BlitzMax: [AUSKLAPPEN]
MeinMin 20, 123 ' einwandfrei
Min 20, 123 ' Fehler

Mit Min aus BlitzMax funktioniert das nicht: Expression of type 'Int' cannot be invoked.

Bis auf Min und Max kann man diese Operatoren sogar ohne Klammern verwenden:
BlitzMax: [AUSKLAPPEN]
y = Abs -5 * Sgn Not 0 - Abs SizeOf y
' entspricht
y = Abs(-5) * Sgn( Not(0) ) - Abs( SizeOf(y) )
' ...
y = SizeOf Varptr y ' funktioniert nicht, weil beide Operatoren gleiche Priorität haben und einander nerven
y = SizeOf (Varptr y) ' geht aber


Das erklärt auch die "Überladbarkeit". Man kann nämlich z.B. Abs mit den Datentypen Int, Long, Float und Double verwenden. Das könnte ich meinem "MeinMin" nicht beibringen, weil BlitzMax keine Überladung beherrscht.

In welcher Weise sind diese Operatoren dann aber Funktionen? Nun, viele davon sind als Funktion implementiert. Es gibt beispielsweise bbFloatAbs (für Float und Double), bbLongAbs (für Long) und bbIntAbs (für Int). Das sind drei Funktionen, die Abs für verschiedene Datentypen implementieren.
Der Compiler generiert einfach für den jeweiligen Datentyp den richtigen Funktionsaufruf und es handelt sich wirklich um einen Funktionsaufruf, obwohl BlitzMax das verschleiert und nicht zulässt, dass die Funktion außerhalb einer Expression aufgerufen wird. Das bezieht sich auf Min, Max, Abs und Sgn.

Und eines fehlt noch: Wenn BlitzMax die Rechnung schon zur Compilezeit auflösen kann, verschwinden diese Aufrufe natürlich.
Der Code von oben (y = Abs -5 * Sgn Not 0 - Abs SizeOf y) kompiliert zu:
Code: [AUSKLAPPEN]
mov eax, 1

Die Rechnung fällt weg und es wird direkt die Zahl 1 geladen.


Unterstützung für Long und Double

In BlitzMax gibt es die Möglichkeit, 64bit Ganzzahlen (Long in BlitzMax) und 64bit double-precision Gleitkommazahlen (Double in BlitzMax) zu verwenden. In Assembler übrigens beide QWORD (weil gleichgroß).
Long Zahlen werden dabei, um nicht bestimmte CPU-Techniken (MMX, SSE ...) vorauszusetzen, von normalen Prozessorbefehlen mit 32bit Registern verarbeitet (weil BlitzMax 32bit Programme generiert).
Double Zahlen werden mit der FPU verarbeitet, die damit zurechtkommt, weil sie intern sogar mit 80bit Registern rechnet.

Was haben beide jetzt miteinander zu tun? Die Operationen werden über Funktionen durchgeführt, die in brl.mod/blitz.mod/blitz_cclib.c definiert sind. Double und Float werden von denselben Funktionen bedient, die alle mit doppelter Genauigkeit arbeiten (der Name der Gleitkomma-Funktionen beginnt trotzdem immer mit bbFloat...) Da diese Prozeduren in C geschrieben sind, wirken die Funktionen natürlich ganz einfach. Beispiel:
Code: [AUSKLAPPEN]
void bbLongMul( BBInt64 *r,BBInt64 x,BBInt64 y ){
   *r=x*y;
}
// oder
double bbFloatSgn( double x ){
   return x==0 ? 0 : (x>0 ? 1 : -1);
}
// sogar Int:
int bbIntMax( int x,int y ){
   return x>y ? x : y;
}


Hier eine Zusammenfassung (aus blitz_cclib.h):
Code: [AUSKLAPPEN]
int      bbIntAbs( int x );         // Abs für Int
int      bbIntSgn( int x );               // Sgn für Int
int      bbIntMod( int x,int y );         // Mod für Int
int      bbIntMin( int x,int y );         // Min für Int
int      bbIntMax( int x,int y );         // Max für Int
void   bbIntToLong( BBInt64 *r,int x );   // Int nach Long casten

double   bbFloatAbs( double x );            // Abs für Float und Double
double   bbFloatSgn( double x );            // Sgn für Float und Double
double   bbFloatPow( double x,double y );   // Potenzieren für Float und Double (3.14^2.0)
double   bbFloatMod( double x,double y );   // Mod für Float und Double
double   bbFloatMin( double x,double y );   // Min für Float und Double
double   bbFloatMax( double x,double y );   // Max für Float und Double
int      bbFloatToInt( double x );         // Float/Double nach Int casten
void   bbFloatToLong( BBInt64 *r,double x );   // Float/Double nach Long casten

void   bbLongNeg( BBInt64 *r,BBInt64 x );   // Long-Wert negieren (also negatives Vorzeichen)
void   bbLongNot( BBInt64 *r,BBInt64 x );   // binäres Nicht für Long (also ~, nicht Not)
void   bbLongAbs( BBInt64 *r,BBInt64 x );   // Abs für Long
void   bbLongSgn( BBInt64 *r,BBInt64 x );   // Sgn für Long
void   bbLongAdd( BBInt64 *r,BBInt64 x,BBInt64 y );   // Addition für Long
void   bbLongSub( BBInt64 *r,BBInt64 x,BBInt64 y );   // Subtraktion für Long
void   bbLongMul( BBInt64 *r,BBInt64 x,BBInt64 y );   // Multiplikation für Long
void   bbLongDiv( BBInt64 *r,BBInt64 x,BBInt64 y );   // Division für Long
void   bbLongMod( BBInt64 *r,BBInt64 x,BBInt64 y );   // Mod für Long
void   bbLongMin( BBInt64 *r,BBInt64 x,BBInt64 y );   // Min für Long
void   bbLongMax( BBInt64 *r,BBInt64 x,BBInt64 y );   // Max für Long
void   bbLongAnd( BBInt64 *r,BBInt64 x,BBInt64 y );   // binäres Und für Long (also &, nicht And)
void   bbLongOrl( BBInt64 *r,BBInt64 x,BBInt64 y );   // binäres Oder für Long (also |, nicht Or)
void   bbLongXor( BBInt64 *r,BBInt64 x,BBInt64 y );   // binäres Xor für Long (also auch ~)
void   bbLongShl( BBInt64 *r,BBInt64 x,BBInt64 y );   // Bitshift nach links (<<) für Long
void   bbLongShr( BBInt64 *r,BBInt64 x,BBInt64 y );   // Bitshift nach rechts (unsigned >>) für Long
void   bbLongSar( BBInt64 *r,BBInt64 x,BBInt64 y );   // arithmetischer Bitshift nach rechts (>>) für Long
int      bbLongSlt( BBInt64 x,BBInt64 y );   // x < y für Long
int      bbLongSgt( BBInt64 x,BBInt64 y );   // x > y für Long
int      bbLongSle( BBInt64 x,BBInt64 y );   // x <= y für Long
int      bbLongSge( BBInt64 x,BBInt64 y );   // x >= y für Long
int      bbLongSeq( BBInt64 x,BBInt64 y );   // x = y für Long
int      bbLongSne( BBInt64 x,BBInt64 y );   // x <> y für Long
double   bbLongToFloat( BBInt64 x );         // Long nach Float/Double casten


Die meisten davon sind Operationen, von denen man meiner Meinung nach erwarten könnte, dass sie direkt implementiert sind, sie stehen aber in einer eigenen Funktion. Wahrscheinlich ist es übersichtlicher, es so zu implementieren, aber man muss dazusagen, dass bei solchen Int/Long/Float/Double-Operationen dann natürlich der zusätzliche Funktionsaufruf mehr Zeit in Anspruch nimmt, als wenn der Code inline wäre (was eventuell aber in mehr Maschinencode resultieren würde).

Zusatz vom 10.02.2016
Konstruktoren und Finalizer

Da ich gerade (gewollt) viel mit C++ zu tun habe, habe ich mir Gedanken darüber
gemacht, wie BlitzMax Konstruktor und Finalizer handhabt (im Fall von Sprachen
mit Garbage Collector spricht man nicht von Destruktoren, habe ich gemerkt).
Also habe ich diese untersucht.
BlitzMax: [AUSKLAPPEN]
Type A
Method New()
Print "Das ist der Konstruktor"
EndMethod

Method Delete()
Print "Das ist der Finalizer"
EndMethod
EndType

Wichtige Erkenntnisse

0. Finalizer sind nicht deterministisch
Das ist der nullte Punkt, weil ich denke, dass er bekannt ist. Aber zur Sicherheit
will ich es erwähnen, dass es nicht bestimmt ist, wann der Finalizer eines
Objekts aufgerufen wird. Ein Objekt wird vom GC dann freigegeben, wenn alle
Referenzen auf es verschwunden sind. Das merkt der GC aber typischerweise nicht
sofort nach dem es passiert ist.
Also ist es nicht klar, wann der Aufruf passiert.

1. Keine Garantie für Finalizer Aufruf
Finalizer oder Destruktoren sind dazu da, um ein Objekt anständig wegzuräumen.
Also z.B. freigeben von Speicher, der nicht vom GC gemanaget wird. Allerdings
gibt es in BlitzMax keine Garantie, dass der Finalizer eines Objektes jemals
aufgerufen wird. Es kann durchaus passieren, dass Finalizer von bestimmten
Objekten überhaupt nicht aufgerufen werden.
Also sollte man sich nicht auf den Finalizer verlassen.

2. Die Finalizer nicht-konstruierter Objekte werden möglicherweise aufgerufen
Wenn ein Objekt nicht konstruiert werden konnte (weil der Konstruktor gethrowt
hat), ist das Objekt nicht valide. Dennoch ruft BlitzMax möglicherweise den
Finalizer auf, was nicht dem entspricht, was ein C++ Entwickler erwarten würde.
Also auch hier ist Vorsicht geboten. Wenn der Konstruktor throwt, oder eine
Funktion aufruft, die throwt, dann ist das Objekt ungültig, es beginnt das
stack unwinding, aber der Finalizer des Objekts wird möglicherweise noch
aufgerufen und dann bekommt man möglicherweise einen Speicherzugriffsfehler um
die Ohren.

3. Finalizer dürfen niemals throwen
Wenn irgendein Finalizer im Laufe des BlitzMax-Programms aufgerufen wird und
dieser oder eine Funktion, die er aufruft, throwt, dann wird an der Stelle, wo
das Programm gerade ist (wo man es möglicherweise nicht erwartet), diese
Exception rausgeworfen. Nochmal: An irgendeiner stelle im Programm, gerade dann,
wenn der GC entscheidet, es wäre gut, ein bisschen Speicher freizumachen, und
er ruft den Finalizer des Objekts auf, throwt er und die Exception ist da.
Und wo kein Catch, dort führt das zum Beenden des Programms.
Daher sollte man (das gilt auch in C++) immer darauf achten, dass Finalizer nicht
throwen können.

Schlusswort

Das sind großteils Dinge, die ich mir selbst erarbeitet habe. Wenn ihr einen Fehler findet oder etwas, das suboptimal erklärt ist, würde ich gerne eure Meinung dazu hören! Smile
Wenn ihr selber noch etwas habt, das ihr beitragen wollt, wenn euch etwas fehlt, freue ich mich natürlich auch über Anregungen!
Erweiterungen von meiner Seite folgen eventuell auch noch, wenn ich selbst auf noch etwas interessantes stoßen sollte.
  • Zuletzt bearbeitet von Thunder am Mi, Feb 10, 2016 2:10, insgesamt einmal bearbeitet

DAK

BeitragDi, Jul 02, 2013 12:41
Antworten mit Zitat
Benutzer-Profile anzeigen
Das ist mal was wirklich interessantes. Danke für's Teilen!

Bist du zufällig auch drauf gekommen, warum Funktionsaufrufe so extrem langsam sind?
Gewinner der 6. und der 68. BlitzCodeCompo

Thunder

BeitragDi, Jul 02, 2013 22:26
Antworten mit Zitat
Benutzer-Profile anzeigen
Danke für die Rückmeldung! Smile

Habe mir das angesehen mit den Funktionen (hast übrigens eine PN von mir) und ehrlich gesagt... die Funktionsaufrufe generell sind nicht das Problem. Die Optimierungen des BlitzMax-Compilers sind teilweise extrem gut (er lastet zuerst alle Prozessor-Register aus, bevor er eine lokale Variable auf dem Stack anlegt), aber teilweise generiert er auch Mist.

Ich gehe von folgendem BlitzMax-Code aus:
BlitzMax: [AUSKLAPPEN]
SuperStrict
Framework brl.blitz


Global i:Int

Local time:Int, x:Int, y:Int

time = MilliSecs()
For x = 0 Until 8192
For y = 0 Until 8192
seti geti()+1
Next
Next
time = MilliSecs() - time

WriteStdout time+"~n"

time = MilliSecs()
For x = 0 Until 8192
For y = 0 Until 8192
i :+ 1
Next
Next
time = MilliSecs() - time

WriteStdout time+"~n"


Function geti:Int()
Return i
EndFunction

Function seti(z:Int)
i = z
EndFunction

Das gleiche in C:
Code: [AUSKLAPPEN]
#include <stdio.h>
#include <stdlib.h>
#include <time.h>

static int i;


int getI(void){ return i; }
void setI(int x){ i = x; }


int main(int argc, char **argv){
   int x, y;
   clock_t time;
   
   i = 0;
   time = clock();
   
   for (x = 0; x < 8192; x++) {
      for (y = 0; y < 8192; y++) {
         setI(getI()+1);
      }
   }
   
   time = clock()-time;
   
   printf("Zeit: %d\n", time*1000/CLOCKS_PER_SEC);
   
   i = 0;
   time = clock();
   
   for (x = 0; x < 8192; x++) {
      for (y = 0; y < 8192; y++) {
         i++;
      }
   }
   
   time = clock()-time;
   
   printf("Zeit: %d\n", time*1000/CLOCKS_PER_SEC);
   
   return 0;
}


Die beiden Funktionen getI() und setI() kompilieren bei BlitzMax zu:
Code: [AUSKLAPPEN]
_bb_geti:
   push   ebp
   mov   ebp,esp
   mov   eax,dword [_bb_i]
   jmp   _16  ; unnötiger Sprung
_16:
   mov   esp,ebp
   pop   ebp
   ret

_bb_seti:
   push   ebp
   mov   ebp,esp
   mov   eax,dword [ebp+8]
   mov   dword [_bb_i],eax
   mov   eax,0 ; unnötiges 'Return 0'
   jmp   _19   ; unnötiger Sprung
_19:
   mov   esp,ebp
   pop   ebp
   ret


Die Zeilen, die weggelassen werden könnten, sind eingezeichnet. Zusätzlich könnte man auf einfache Weise zumindest in der Funktion getI() den Stackframe entfernen (also die ersten zwei Befehle push und mov sowie die vorletzten Befehle mov und pop).

Außerdem legt gcc den Parameter für setI() anders auf den Stack:
Code: [AUSKLAPPEN]
   ; gcc:
   movl   %eax, (%esp) ; vor der Schleife wird esp entsprechend angepasst
   call   _setI

; BlitzMax:
   push   eax
   call   _bb_seti
   add   esp,4


Das sind (meiner Meinung nach) die gravierendsten Unterschiede zwischen dem unoptimierten Assemblercode von gcc sowie dem Assemblercode von BlitzMax.

Okay folgendes...
Zitat:
BlitzMax direkter Zugriff 2^26 Mal: ~ 220 ms
BlitzMax Zugriff über Funktion 2^26 Mal: ~ 650 ms
gcc (unoptimiert) direkter Zugriff 2^26 Mal: ~260 ms
gcc (unoptimiert) Zugriff über Funktion 2^26 Mal: ~440 ms

Wenn man den Code von BlitzMax nimmt und ihn etwas von Hand optimiert und den Mist raushaut, der nur bremst und keinen Sinn macht, gewinnt man etwa 200 ms und liegt ungefähr beim gcc. Lässt man noch den Stackframe von getI() weg, kommt man auf etwa 350 ms und liegt vor dem gcc.

gcc generiert zwar auch keinen optimalen Code (wenn man Optimierung einschaltet berechnet er das Ergebnis von i zur Compilezeit und lässt beide Schleifen weg!), aber man kann denke ich sagen, die Geschwindigkeit von unoptimiertem gcc Code ist in Ordnung.

Es liegt einfach vieles daran, dass BlitzMax da und dort suboptimalen Code erzeugt. Und btw: es geht zwar hier um zwei- bis dreifache Geschwindigkeiten, aber um es nochmal hervorzuheben: 2^26 Aufrufe sind 67.108.864. Ich bin ehrlich gesagt richtig froh, dass Computer so schnell sind - immerhin: 67 Millionen Aufrufe brauchen wesentlich weniger als eine Sekunde.

Was mir übrigens noch aufgefallen ist (und vielleicht editiere ich dazu noch was in meinen ersten Post, wenn ich mehr weiß): BlitzMax kompiliert If-Ausdrücke seeehr komisch!
BlitzMax: [AUSKLAPPEN]
If getI() = x Then If getI() = y Then blabla 
' ist schneller als
If getI() = x And getI() = y Then blabla

Das sehe ich mir noch genauer an!
Meine Sachen: https://bitbucket.org/chtisgit https://github.com/chtisgit

Neue Antwort erstellen


Übersicht BlitzMax, BlitzMax NG FAQs und Tutorials

Gehe zu:

Powered by phpBB © 2001 - 2006, phpBB Group