Ich untersuche derzeit Wege, um die schnelle Gleitkomma-Reziprokfähigkeit verschiedener moderner Prozessoren zu verwenden, um eine Startapproximation für ein 64-Bit zu berechnen vorzeichenlose ganzzahlige Division basierend auf Festkomma-Newton-Raphson-Iterationen. Sie erfordert eine möglichst genaue Berechnung des Divisors, wobei die anfängliche Approximation kleiner oder gleich dem mathematischen Ergebnis sein muss, basierend auf den Anforderungen der folgenden Festkomma-Iterationen. Dies bedeutet, dass diese Berechnung zu unterschätzen ist. Im Moment habe ich den folgenden Code, der gut funktioniert, basierend auf umfangreichen Tests:Effiziente Berechnung von 2 ** 64/Divisor über schnellen Fließkomma-Reziprok
#include <stdint.h> // import uint64_t
#include <math.h> // import nextafterf()
uint64_t divisor, recip;
float r, s, t;
t = uint64_to_float_ru (divisor); // ensure t >= divisor
r = 1.0f/t;
s = 0x1.0p64f * nextafterf (r, 0.0f);
recip = (uint64_t)s; // underestimate of 2**64/divisor
Während dieser Code funktioniert, es ist nicht gerade schnell auf den meisten Plattformen. Eine offensichtliche Verbesserung, die ein wenig maschinenspezifischen Code erfordert, besteht darin, die Division r = 1.0f/t
durch einen Code zu ersetzen, der von der Hardware bereitgestellte schnelle Fließkomma-Reziprokwerte verwendet. Dies kann durch Iteration erhöht werden, um ein Ergebnis zu erzeugen, das innerhalb von 1 ul des mathematischen Ergebnisses liegt, so dass im Kontext des bestehenden Codes eine Unterschätzung erzeugt wird. Eine Beispielimplementierung für x86_64 wäre:
#include <xmmintrin.h>
/* Compute 1.0f/a almost correctly rounded. Halley iteration with cubic convergence */
inline float fast_recip_f32 (float a)
{
__m128 t;
float e, r;
t = _mm_set_ss (a);
t = _mm_rcp_ss (t);
_mm_store_ss (&r, t);
e = fmaf (r, -a, 1.0f);
e = fmaf (e, e, e);
r = fmaf (e, r, r);
return r;
}
Implementationen von nextafterf()
sind in der Regel nicht die Leistung optimiert. Auf Plattformen, wo es Mittel gibt, um schnell eine IEEE 754 binary32
in eine int32
und umgekehrt über intrinsics float_as_int()
und int_as_float()
, können wir den Einsatz von nextafterf()
und Skalierung kombinieren neu interpretiert wie folgt: Unter der Annahme,
s = int_as_float (float_as_int (r) + 0x1fffffff);
diese Ansätze Auf einer gegebenen Plattform möglich, lassen uns die Umbauten zwischen float
und uint64_t
als Haupthindernisse. Die meisten Plattformen bieten keine Anweisung, die eine Konvertierung von uint64_t
zu float
mit statischem Rundungsmodus (hier: in Richtung positive Unendlichkeit = aufwärts) durchführt, und einige bieten keine Anweisungen zum Konvertieren zwischen uint64_t
und Gleitkommatypen ein Leistungsengpass.
t = uint64_to_float_ru (divisor);
r = fast_recip_f32 (t);
s = int_as_float (float_as_int (r) + 0x1fffffff);
recip = (uint64_t)s; /* underestimate of 2**64/divisor */
Eine tragbare, aber langsam, Implementierung von uint64_to_float_ru
verwendet dynamische Änderungen FPU-Rundungsmodus:
#include <fenv.h>
#pragma STDC FENV_ACCESS ON
float uint64_to_float_ru (uint64_t a)
{
float res;
int curr_mode = fegetround();
fesetround (FE_UPWARD);
res = (float)a;
fesetround (curr_mode);
return res;
}
ich verschiedene Splitting und Bit-Fummel Ansätze ausgesehen habe in dem Conversions zu beschäftigen (zB tun die Rundung auf der Ganzzahlseite, dann verwenden Sie eine normale Umwandlung zu float
, die den Rundungsmodus IEEE 754 Round-to-Nearest-or-even verwendet, aber der dabei entstehende Overhead macht diese Berechnung über schnelle reziproke Gleitkommazahl aus einer Performance Perspektive. So wie es aussieht, scheint es mir besser zu sein, eine Anfangsapproximation zu erzeugen, indem ich eine klassische LUT mit Interpolation oder eine Festkomma-Polynomapproximation benutze, und folge diesen mit einem 32-Bit-Festkomma-Newton-Raphson-Schritt.
Gibt es Möglichkeiten, die Effizienz meines derzeitigen Ansatzes zu verbessern? Portable und semi-portable Wege mit intrinsic für spezifische Plattformen wären von Interesse (insbesondere für x86 und ARM als die derzeit dominierenden CPU-Architekturen). Kompilieren für x86_64 unter Verwendung des Intel-Compilers bei sehr hoher Optimierung (/O3 /QxCORE-AVX2 /Qprec-div-
) erfordert die Berechnung der anfänglichen Approximation mehr Anweisungen als die Iteration, die ungefähr 20 Anweisungen benötigt. Unten ist der vollständige Divisionscode als Referenz angegeben, der die Approximation im Kontext zeigt.
uint64_t udiv64 (uint64_t dividend, uint64_t divisor)
{
uint64_t temp, quot, rem, recip, neg_divisor = 0ULL - divisor;
float r, s, t;
/* compute initial approximation for reciprocal; must be underestimate! */
t = uint64_to_float_ru (divisor);
r = 1.0f/t;
s = 0x1.0p64f * nextafterf (r, 0.0f);
recip = (uint64_t)s; /* underestimate of 2**64/divisor */
/* perform Halley iteration with cubic convergence to refine reciprocal */
temp = neg_divisor * recip;
temp = umul64hi (temp, temp) + temp;
recip = umul64hi (recip, temp) + recip;
/* compute preliminary quotient and remainder */
quot = umul64hi (dividend, recip);
rem = dividend - divisor * quot;
/* adjust quotient if too small; quotient off by 2 at most */
if (rem >= divisor) quot += ((rem - divisor) >= divisor) ? 2 : 1;
/* handle division by zero */
if (divisor == 0ULL) quot = ~0ULL;
return quot;
}
umul64hi()
würde Karte im Allgemeinen auf ein plattformspezifische intrinsisch, oder ein bisschen von Inline-Assembler-Code. Auf x86_64 verwende ich zur Zeit diese Implementierung:
inline uint64_t umul64hi (uint64_t a, uint64_t b)
{
uint64_t res;
__asm__ (
"movq %1, %%rax;\n\t" // rax = a
"mulq %2;\n\t" // rdx:rax = a * b
"movq %%rdx, %0;\n\t" // res = (a * b)<63:32>
: "=rm" (res)
: "rm"(a), "rm"(b)
: "%rax", "%rdx");
return res;
}
Da Floating point reziproced ist eine naheliegende und übliche Operation, sollte Ihr Compiler nicht schlau genug sein, um optimierten Code dafür auszusenden, vorausgesetzt, Ihre ISA unterstützt sie und Sie haben dem Compiler das so gesagt? –
@JohnZwinck Vielleicht :-) Meist geht es dabei um Compiler-Switches, die anderen Code in unerwünschter Weise negativ beeinflussen. Intrinsics sind in Ordnung, sie können oft in eine Reihe von "generischen Intrinsics" abstrahiert werden, die sich eng an plattformspezifische abbilden (siehe den SIMD-Quellcode für GROMACS als ein funktionierendes Beispiel). In jedem Fall ist das Floating-Point-Reziprozedum hier nicht wirklich mein Problem, die Conversions bringen mich um (außer auf GPUs). – njuffa
Haben Sie einen Benchmark erstellt? Wie? Welche Zieldetails? Welche Werkzeugkette? Was war das Ergebnis? Warum denkst du, dass "Fiedeln mit Compiler-Schaltern" für deinen Code nicht erforderlich ist? Wenn Sie den generierten Code vollständig kontrollieren wollen, müssen Sie eventuell Assembler verwenden. – Olaf