Ich habe einige unserer Kernmathematiken auf einem Intel Core Duo profiliert und bei der Betrachtung verschiedener Ansätze zur Quadratwurzel etwas Seltsames festgestellt: Mit den skalaren SSE-Operationen ist es schneller, eine reziproke Quadratwurzel zu nehmen und zu multiplizieren um das sqrt zu erhalten, muss der native sqrt-opcode verwendet werden!
Ich teste es mit einer Schleife wie:
inline float TestSqrtFunction( float in );
void TestFunc()
{
#define ARRAYSIZE 4096
#define NUMITERS 16386
float flIn[ ARRAYSIZE ]; // filled with random numbers ( 0 .. 2^22 )
float flOut [ ARRAYSIZE ]; // filled with 0 to force fetch into L1 cache
cyclecounter.Start();
for ( int i = 0 ; i < NUMITERS ; ++i )
for ( int j = 0 ; j < ARRAYSIZE ; ++j )
{
flOut[j] = TestSqrtFunction( flIn[j] );
// unrolling this loop makes no difference -- I tested it.
}
cyclecounter.Stop();
printf( "%d loops over %d floats took %.3f milliseconds",
NUMITERS, ARRAYSIZE, cyclecounter.Milliseconds() );
}
Ich habe dies mit ein paar verschiedenen Körpern für die TestSqrtFunction versucht, und ich habe einige Timings, die mir wirklich am Kopf kratzen. Das mit Abstand Schlimmste war, die native Funktion sqrt () zu verwenden und den "intelligenten" Compiler "optimieren" zu lassen. Bei 24 ns / float war dies mit der x87-FPU erbärmlich schlecht:
inline float TestSqrtFunction( float in )
{ return sqrt(in); }
Das nächste, was ich versuchte, war die Verwendung eines Intrinsic, um den Compiler zu zwingen, den skalaren Sqrt-Opcode von SSE zu verwenden:
inline void SSESqrt( float * restrict pOut, float * restrict pIn )
{
_mm_store_ss( pOut, _mm_sqrt_ss( _mm_load_ss( pIn ) ) );
// compiles to movss, sqrtss, movss
}
Dies war besser bei 11,9 ns / float. Ich habe auch Carmacks verrückte Newton-Raphson-Approximationstechnik ausprobiert , die mit 4,3 ns / float sogar noch besser lief als die Hardware, allerdings mit einem Fehler von 1 zu 2 10 (was für meine Zwecke zu viel ist).
Der Trottel war, als ich die SSE-Operation für die reziproke Quadratwurzel ausprobierte und dann eine Multiplikation verwendete, um die Quadratwurzel zu erhalten (x * 1 / √x = √x). Auch wenn diese beiden abhängigen Operationen nimmt, war es die schnellste Lösung bei weitem, bei 1.24ns / Schwimmer und genau 2 -14 :
inline void SSESqrt_Recip_Times_X( float * restrict pOut, float * restrict pIn )
{
__m128 in = _mm_load_ss( pIn );
_mm_store_ss( pOut, _mm_mul_ss( in, _mm_rsqrt_ss( in ) ) );
// compiles to movss, movaps, rsqrtss, mulss, movss
}
Meine Frage ist im Grunde, was gibt ? Warum ist der in die Hardware integrierte Quadratwurzel-Opcode von SSE langsamer als die Synthese aus zwei anderen mathematischen Operationen?
Ich bin mir sicher, dass dies wirklich die Kosten für die Operation selbst sind, da ich Folgendes überprüft habe:
- Alle Daten passen in den Cache und die Zugriffe erfolgen nacheinander
- Die Funktionen sind inline
- Das Abrollen der Schleife macht keinen Unterschied
- Compiler-Flags sind auf vollständige Optimierung gesetzt (und die Assembly ist gut, habe ich überprüft)
( edit : stephentyrone weist korrekt darauf hin, dass Operationen an langen Zahlenfolgen die vektorisierenden SIMD-gepackten Operationen verwenden sollten, wie rsqrtps
- aber die Array-Datenstruktur hier dient nur zu Testzwecken: Was ich wirklich zu messen versuche, ist die skalare Leistung für die Verwendung in Code das kann nicht vektorisiert werden.)
inline float SSESqrt( float restrict fIn ) { float fOut; _mm_store_ss( &fOut, _mm_sqrt_ss( _mm_load_ss( &fIn ) ) ); return fOut; }
. Dies ist jedoch eine schlechte Idee, da es leicht zu einem Load-Hit-Store-Stall kommen kann, wenn die CPU die Floats in den Stack schreibt und sie dann sofort zurückliest - insbesondere für den Rückgabewert vom Vektorregister in ein Float-Register jonglieren ist eine schlechte Nachricht. Außerdem nehmen die zugrunde liegenden Maschinen-Opcodes, die die SSE-Intrinsics darstellen, ohnehin Adressoperanden an.
eax
) bei i7 bis zu einem sehr schlechten Wert ist, während ein Roundtrip zwischen xmm0 und Stack erfolgt und zurück nicht, wegen Intels Store-Weiterleitung. Sie können es selbst zeitlich festlegen, um sicher zu sehen. Im Allgemeinen ist es am einfachsten, potenzielle LHS zu erkennen, indem Sie sich die emittierte Baugruppe ansehen und feststellen, wo Daten zwischen Registersätzen jongliert werden. Ihr Compiler macht möglicherweise das Schlaue oder nicht. Was die Normalisierung von Vektoren betrifft