Angenommen, a1
, b1
, c1
und d1
zeigen auf Heap-Speicher, und mein numerischer Code hat die folgende Kernschleife.
const int n = 100000;
for (int j = 0; j < n; j++) {
a1[j] += b1[j];
c1[j] += d1[j];
}
Diese Schleife wird 10.000 Mal über eine andere äußere for
Schleife ausgeführt. Um dies zu beschleunigen, habe ich den Code folgendermaßen geändert:
for (int j = 0; j < n; j++) {
a1[j] += b1[j];
}
for (int j = 0; j < n; j++) {
c1[j] += d1[j];
}
Kompiliert unter MS Visual C++ 10. mit vollständiger Optimierung und SSE2 aktiviert für 32-Bit auf einem Intel Core 2 Duo (x64), dem ersten Beispiel dauert 5,5 Sekunden und das Doppelschleifenbeispiel dauert nur 1,9 Sekunden. Meine Frage lautet: (Siehe meine umformulierte Frage unten.)
PS: Ich bin mir nicht sicher, ob dies hilft:
Das Zerlegen für die erste Schleife sieht im Prinzip so aus (dieser Block wird im gesamten Programm etwa fünfmal wiederholt):
movsd xmm0,mmword ptr [edx+18h]
addsd xmm0,mmword ptr [ecx+20h]
movsd mmword ptr [ecx+20h],xmm0
movsd xmm0,mmword ptr [esi+10h]
addsd xmm0,mmword ptr [eax+30h]
movsd mmword ptr [eax+30h],xmm0
movsd xmm0,mmword ptr [edx+20h]
addsd xmm0,mmword ptr [ecx+28h]
movsd mmword ptr [ecx+28h],xmm0
movsd xmm0,mmword ptr [esi+18h]
addsd xmm0,mmword ptr [eax+38h]
Jede Schleife des Doppelschleifenbeispiels erzeugt diesen Code (der folgende Block wird ungefähr dreimal wiederholt):
addsd xmm0,mmword ptr [eax+28h]
movsd mmword ptr [eax+28h],xmm0
movsd xmm0,mmword ptr [ecx+20h]
addsd xmm0,mmword ptr [eax+30h]
movsd mmword ptr [eax+30h],xmm0
movsd xmm0,mmword ptr [ecx+28h]
addsd xmm0,mmword ptr [eax+38h]
movsd mmword ptr [eax+38h],xmm0
movsd xmm0,mmword ptr [ecx+30h]
addsd xmm0,mmword ptr [eax+40h]
movsd mmword ptr [eax+40h],xmm0
Die Frage stellte sich als nicht relevant heraus, da das Verhalten stark von der Größe der Arrays (n) und dem CPU-Cache abhängt. Wenn es also weiteres Interesse gibt, formuliere ich die Frage neu:
Können Sie einen guten Einblick in die Details geben, die zu den unterschiedlichen Cache-Verhaltensweisen führen, wie sie in den fünf Regionen in der folgenden Grafik dargestellt sind?
Es könnte auch interessant sein, auf die Unterschiede zwischen CPU/Cache-Architekturen hinzuweisen, indem für diese CPUs ein ähnliches Diagramm bereitgestellt wird.
PPS: Hier ist der vollständige Code. Es verwendet TBBTick_Count
für das Timing mit höherer Auflösung, das deaktiviert werden kann, indem das TBB_TIMING
-Makro nicht definiert wird:
#include <iostream>
#include <iomanip>
#include <cmath>
#include <string>
//#define TBB_TIMING
#ifdef TBB_TIMING
#include <tbb/tick_count.h>
using tbb::tick_count;
#else
#include <time.h>
#endif
using namespace std;
//#define preallocate_memory new_cont
enum { new_cont, new_sep };
double *a1, *b1, *c1, *d1;
void allo(int cont, int n)
{
switch(cont) {
case new_cont:
a1 = new double[n*4];
b1 = a1 + n;
c1 = b1 + n;
d1 = c1 + n;
break;
case new_sep:
a1 = new double[n];
b1 = new double[n];
c1 = new double[n];
d1 = new double[n];
break;
}
for (int i = 0; i < n; i++) {
a1[i] = 1.0;
d1[i] = 1.0;
c1[i] = 1.0;
b1[i] = 1.0;
}
}
void ff(int cont)
{
switch(cont){
case new_sep:
delete[] b1;
delete[] c1;
delete[] d1;
case new_cont:
delete[] a1;
}
}
double plain(int n, int m, int cont, int loops)
{
#ifndef preallocate_memory
allo(cont,n);
#endif
#ifdef TBB_TIMING
tick_count t0 = tick_count::now();
#else
clock_t start = clock();
#endif
if (loops == 1) {
for (int i = 0; i < m; i++) {
for (int j = 0; j < n; j++){
a1[j] += b1[j];
c1[j] += d1[j];
}
}
} else {
for (int i = 0; i < m; i++) {
for (int j = 0; j < n; j++) {
a1[j] += b1[j];
}
for (int j = 0; j < n; j++) {
c1[j] += d1[j];
}
}
}
double ret;
#ifdef TBB_TIMING
tick_count t1 = tick_count::now();
ret = 2.0*double(n)*double(m)/(t1-t0).seconds();
#else
clock_t end = clock();
ret = 2.0*double(n)*double(m)/(double)(end - start) *double(CLOCKS_PER_SEC);
#endif
#ifndef preallocate_memory
ff(cont);
#endif
return ret;
}
void main()
{
freopen("C:\\test.csv", "w", stdout);
char *s = " ";
string na[2] ={"new_cont", "new_sep"};
cout << "n";
for (int j = 0; j < 2; j++)
for (int i = 1; i <= 2; i++)
#ifdef preallocate_memory
cout << s << i << "_loops_" << na[preallocate_memory];
#else
cout << s << i << "_loops_" << na[j];
#endif
cout << endl;
long long nmax = 1000000;
#ifdef preallocate_memory
allo(preallocate_memory, nmax);
#endif
for (long long n = 1L; n < nmax; n = max(n+1, long long(n*1.2)))
{
const long long m = 10000000/n;
cout << n;
for (int j = 0; j < 2; j++)
for (int i = 1; i <= 2; i++)
cout << s << plain(n, m, j, i);
cout << endl;
}
}
(Es zeigt FLOP/s für verschiedene Werte von n
.)
Nach weiterer Analyse ist dies meines Erachtens (zumindest teilweise) auf die Datenausrichtung der vier Zeiger zurückzuführen. Dies führt zu Konflikten zwischen Cache-Bänken und -Pfaden.
Wenn ich richtig geraten habe, wie Sie Ihre Arrays zuordnen, werden diese wahrscheinlich an der Seitenzeile ausgerichtet.
Dies bedeutet, dass alle Ihre Zugriffe in jeder Schleife auf den gleichen Cache-Weg fallen. Intel-Prozessoren verfügen jedoch seit einiger Zeit über eine 8-Wege-L1-Cache-Assoziativität. In Wirklichkeit ist die Leistung jedoch nicht ganz einheitlich. Der Zugriff auf 4-Wege ist immer noch langsamer als der Zugriff auf 2-Wege.
BEARBEITEN: Es sieht tatsächlich so aus, als würden Sie alle Arrays separat zuweisen. Normalerweise fordert der Allokator bei der Anforderung so großer Zuweisungen neue Seiten vom Betriebssystem an. Daher besteht eine hohe Wahrscheinlichkeit, dass große Zuordnungen mit demselben Versatz von einer Seitengrenze angezeigt werden.
Hier ist der Testcode:
int main(){
const int n = 100000;
#ifdef ALLOCATE_SEPERATE
double *a1 = (double*)malloc(n * sizeof(double));
double *b1 = (double*)malloc(n * sizeof(double));
double *c1 = (double*)malloc(n * sizeof(double));
double *d1 = (double*)malloc(n * sizeof(double));
#else
double *a1 = (double*)malloc(n * sizeof(double) * 4);
double *b1 = a1 + n;
double *c1 = b1 + n;
double *d1 = c1 + n;
#endif
// Zero the data to prevent any chance of denormals.
memset(a1,0,n * sizeof(double));
memset(b1,0,n * sizeof(double));
memset(c1,0,n * sizeof(double));
memset(d1,0,n * sizeof(double));
// Print the addresses
cout << a1 << endl;
cout << b1 << endl;
cout << c1 << endl;
cout << d1 << endl;
clock_t start = clock();
int c = 0;
while (c++ < 10000){
#if ONE_LOOP
for(int j=0;j<n;j++){
a1[j] += b1[j];
c1[j] += d1[j];
}
#else
for(int j=0;j<n;j++){
a1[j] += b1[j];
}
for(int j=0;j<n;j++){
c1[j] += d1[j];
}
#endif
}
clock_t end = clock();
cout << "seconds = " << (double)(end - start) / CLOCKS_PER_SEC << endl;
system("pause");
return 0;
}
Benchmark-Ergebnisse:
2 x Intel Xeon X5482 Harpertown bei 3,2 GHz:
#define ALLOCATE_SEPERATE
#define ONE_LOOP
00600020
006D0020
007A0020
00870020
seconds = 6.206
#define ALLOCATE_SEPERATE
//#define ONE_LOOP
005E0020
006B0020
00780020
00850020
seconds = 2.116
//#define ALLOCATE_SEPERATE
#define ONE_LOOP
00570020
00633520
006F6A20
007B9F20
seconds = 1.894
//#define ALLOCATE_SEPERATE
//#define ONE_LOOP
008C0020
00983520
00A46A20
00B09F20
seconds = 1.993
Beobachtungen:
6.206 Sekunden mit einer Schleife und 2.116 Sekunden mit zwei Schleifen. Dies gibt die Ergebnisse des OP exakt wieder.
In den ersten beiden Tests werden die Arrays separat zugewiesen. Sie werden feststellen, dass sie alle die gleiche Ausrichtung in Bezug auf die Seite haben.
In den zweiten beiden Tests werden die Arrays zusammengepackt, um diese Ausrichtung zu unterbrechen. Hier werden Sie feststellen, dass beide Schleifen schneller sind. Außerdem ist die zweite (Doppel-) Schleife jetzt die langsamere, wie Sie es normalerweise erwarten würden.
Wie @Stephen Cannon in den Kommentaren ausführt, besteht sehr wahrscheinlich die Möglichkeit, dass diese Ausrichtung falsches Aliasing in den Lade-/Speichereinheiten oder im Cache verursacht. Ich habe dafür gegoogelt und festgestellt, dass Intel tatsächlich einen Hardwarezähler für Partial Address Aliasing Stalls hat:
Region 1:
Dieser ist einfach. Der Datensatz ist so klein, dass die Leistung von Overhead wie Schleifen und Verzweigungen dominiert wird.
Region 2:
Hier nimmt mit zunehmender Datengröße der relative Overhead ab und die Leistung "sättigt" sich. Hier sind zwei Schleifen langsamer, weil sie doppelt so viel Schleifen- und Verzweigungsaufwand haben.
Ich bin mir nicht sicher, was genau hier vor sich geht ... Die Ausrichtung könnte immer noch einen Effekt haben, wenn Agner Fog erwähnt Cache-Bank-Konflikte . (Dieser Link handelt von Sandy Bridge, aber die Idee sollte immer noch für Core 2 gelten.)
Region 3:
Zu diesem Zeitpunkt passen die Daten nicht mehr in den L1-Cache. Die Leistung wird also durch die L1 <-> L2-Cache-Bandbreite begrenzt.
Region 4:
Der Leistungsabfall in der Einzelschleife ist das, was wir beobachten. Und wie bereits erwähnt, liegt dies an der Ausrichtung, die (höchstwahrscheinlich) falsches Aliasing in den Lade-/Speichereinheiten des Prozessors verursacht.
Damit jedoch ein falsches Aliasing auftritt, muss ein ausreichender Abstand zwischen den Datasets vorhanden sein. Aus diesem Grund wird dies in Region 3 nicht angezeigt.
Region 5:
Zu diesem Zeitpunkt passt nichts in den Cache. Sie sind also an die Speicherbandbreite gebunden.
OK, die richtige Antwort hat definitiv etwas mit dem CPU-Cache zu tun. Die Verwendung des Cache-Arguments kann jedoch sehr schwierig sein, insbesondere ohne Daten.
Es gibt viele Antworten, die zu vielen Diskussionen geführt haben, aber seien wir ehrlich: Cache-Probleme können sehr komplex und nicht eindimensional sein. Sie hängen stark von der Größe der Daten ab, daher war meine Frage unfair: Es stellte sich heraus, dass es sich um einen sehr interessanten Punkt im Cache-Diagramm handelt.
Die Antwort von @Mysticial überzeugte viele Leute (einschließlich mich), wahrscheinlich, weil es der einzige war, der sich auf Fakten zu verlassen schien, aber es war nur ein "Datenpunkt" der Wahrheit.
Deshalb habe ich seinen Test (mit einer fortlaufenden vs. getrennten Zuordnung) und den Rat von @James 'Answer kombiniert.
Die nachstehenden Grafiken zeigen, dass die meisten Antworten und insbesondere die meisten Kommentare zu den Fragen und Antworten je nach verwendetem Szenario und verwendeten Parametern als völlig falsch oder wahr eingestuft werden können.
Beachten Sie, dass meine erste Frage bei n = 100.0 war. Dieser Punkt weist (aus Versehen) ein besonderes Verhalten auf:
Es weist die größte Diskrepanz zwischen der Version mit einer und zwei Schleifen auf (fast ein Faktor drei).
Es ist der einzige Punkt, an dem Ein-Loop (nämlich mit kontinuierlicher Zuordnung) die Zwei-Loop-Version schlägt. (Dies ermöglichte Mystikals Antwort überhaupt.)
Das Ergebnis mit initialisierten Daten:
Das Ergebnis unter Verwendung nicht initialisierter Daten (dies wurde von Mysticial getestet):
Und das ist schwer zu erklären: Initialisierte Daten, die einmal vergeben und für jeden folgenden Testfall unterschiedlicher Vektorgröße wiederverwendet werden:
Jede leistungsbezogene Frage zu Stack Overflow auf niedriger Ebene sollte erforderlich sein, um MFLOPS-Informationen für den gesamten Bereich der Cache-relevanten Datengrößen bereitzustellen! Es ist eine Verschwendung von Zeit, über Antworten nachzudenken und diese ohne diese Informationen mit anderen zu diskutieren.
Die zweite Schleife erfordert viel weniger Cache-Aktivität, sodass der Prozessor den Speicherbedarf leichter bewältigen kann.
Stellen Sie sich vor, Sie arbeiten auf einem Computer, auf dem n
genau der richtige Wert war, um nur zwei Ihrer Arrays gleichzeitig im Speicher zu halten halte alle vier.
Unter der Annahme einer einfachen Caching-Richtlinie LIFO lautet dieser Code:
for(int j=0;j<n;j++){
a[j] += b[j];
}
for(int j=0;j<n;j++){
c[j] += d[j];
}
würde zuerst bewirken, dass a
und b
in RAM geladen und dann vollständig im RAM bearbeitet werden. Wenn die zweite Schleife startet, werden c
und d
von der Festplatte in RAM geladen und bearbeitet.
die andere Schleife
for(int j=0;j<n;j++){
a[j] += b[j];
c[j] += d[j];
}
wird zwei Arrays auslagern und in den anderen zwei blättern jedes Mal um die Schleife. Dies wäre offensichtlich viel langsamer.
Wahrscheinlich sehen Sie in Ihren Tests kein Platten-Caching, aber Sie sehen wahrscheinlich die Nebenwirkungen einer anderen Form des Caching.
Es scheint hier ein wenig Verwirrung/Missverständnis zu geben, deshalb werde ich versuchen, ein wenig anhand eines Beispiels zu erläutern.
Sagen Sie n = 2
und wir arbeiten mit Bytes. In meinem Szenario haben wir also nur 4 Bytes RAM und der Rest unseres Speichers ist bedeutend langsamer (sagen wir 100-mal längerer Zugriff).
Unter der Annahme einer ziemlich dummen Caching-Richtlinie von , wenn sich das Byte nicht im Cache befindet, platzieren Sie es dort und holen Sie sich auch das folgende Byte, während wir dabei sind Holen Sie sich ein Szenario wie folgt:
Mit
for(int j=0;j<n;j++){
a[j] += b[j];
}
for(int j=0;j<n;j++){
c[j] += d[j];
}
cache a[0]
und a[1]
dann b[0]
und b[1]
und setze a[0] = a[0] + b[0]
in den Cache - es sind jetzt vier Bytes im Cache, a[0], a[1]
und b[0], b[1]
. Kosten = 100 + 100.
a[1] = a[1] + b[1]
in den Cache. Kosten = 1 + 1.c
und d
.Gesamtkosten = (100 + 100 + 1 + 1) * 2 = 404
Mit
for(int j=0;j<n;j++){
a[j] += b[j];
c[j] += d[j];
}
cache a[0]
und a[1]
dann b[0]
und b[1]
und setze a[0] = a[0] + b[0]
in den Cache - es sind jetzt vier Bytes im Cache, a[0], a[1]
und b[0], b[1]
. Kosten = 100 + 100.
a[0], a[1], b[0], b[1]
aus dem Cache und Cache c[0]
und c[1]
dann d[0]
und d[1]
und setzen Sie c[0] = c[0] + d[0]
in den Cache. Kosten = 100 + 100.(100 + 100 + 100 + 100) * 2 = 800
Dies ist ein klassisches Cache-Thrash-Szenario.
Dies liegt nicht an einem anderen Code, sondern an der Zwischenspeicherung: RAM ist langsamer als die CPU-Register und es befindet sich ein Cache-Speicher in der CPU, um zu vermeiden, dass jedes Mal eine Variable in RAM geschrieben wird verändert sich. Der Cache ist jedoch nicht so groß wie der RAM, sodass er nur einen Bruchteil davon abbildet.
Der erste Code ändert entfernte Speicheradressen, indem er sie bei jeder Schleife abwechselt, so dass der Cache kontinuierlich ungültig gemacht werden muss.
Der zweite Code wechselt nicht: Er fließt nur zweimal über benachbarte Adressen. Dadurch wird der gesamte Auftrag im Cache abgeschlossen und erst nach dem Start der zweiten Schleife ungültig.
Ich kann die hier diskutierten Ergebnisse nicht replizieren.
Ich weiß nicht, ob ein schlechter Benchmark-Code daran schuld ist oder nicht, aber die beiden Methoden liegen auf meinem Computer mit dem folgenden Code innerhalb von 10% voneinander, und eine Schleife ist normalerweise nur geringfügig schneller als zwei - wie Sie möchten erwarten von.
Die Arraygrößen reichten von 2 ^ 16 bis 2 ^ 24, wobei acht Schleifen verwendet wurden. Ich habe darauf geachtet, die Quell-Arrays zu initialisieren, damit die +=
-Zuweisung das FPU nicht aufforderte, Speichermüll hinzuzufügen, der als Double interpretiert wurde.
Ich habe mit verschiedenen Schemata herumgespielt, wie z. B. die Zuordnung von b[j]
, d[j]
zu InitToZero[j]
in den Schleifen und auch die Verwendung von += b[j] = 1
und += d[j] = 1
, und ich habe ziemlich konsistente Ergebnisse.
Das Initialisieren von b
und d
innerhalb der Schleife mit InitToZero[j]
brachte erwartungsgemäß den Vorteil des kombinierten Ansatzes, da diese vor den Zuweisungen zu a
und c
, aber immer noch innerhalb von 10% durchgeführt wurden. Stelle dir das vor.
Hardware ist Dell XPS 85 mit Generation 3 Core i7 bei 3,4 GHz und 8 GB Speicher. Für 2 ^ 16 bis 2 ^ 24 unter Verwendung von acht Schleifen betrug die kumulative Zeit 44,987 bzw. 40,965. Visual C++ 2010, vollständig optimiert.
PS: Ich habe die Schleifen so geändert, dass sie bis auf Null herunter zählen, und die kombinierte Methode war geringfügig schneller. Ich kratzte mich am Kopf. Beachten Sie die neue Größe des Arrays und die Anzahl der Schleifen.
// MemBufferMystery.cpp : Defines the entry point for the console application.
//
#include "stdafx.h"
#include <iostream>
#include <cmath>
#include <string>
#include <time.h>
#define dbl double
#define MAX_ARRAY_SZ 262145 //16777216 // AKA (2^24)
#define STEP_SZ 1024 // 65536 // AKA (2^16)
int _tmain(int argc, _TCHAR* argv[]) {
long i, j, ArraySz = 0, LoopKnt = 1024;
time_t start, Cumulative_Combined = 0, Cumulative_Separate = 0;
dbl *a = NULL, *b = NULL, *c = NULL, *d = NULL, *InitToOnes = NULL;
a = (dbl *)calloc( MAX_ARRAY_SZ, sizeof(dbl));
b = (dbl *)calloc( MAX_ARRAY_SZ, sizeof(dbl));
c = (dbl *)calloc( MAX_ARRAY_SZ, sizeof(dbl));
d = (dbl *)calloc( MAX_ARRAY_SZ, sizeof(dbl));
InitToOnes = (dbl *)calloc( MAX_ARRAY_SZ, sizeof(dbl));
// Initialize array to 1.0 second.
for(j = 0; j< MAX_ARRAY_SZ; j++) {
InitToOnes[j] = 1.0;
}
// Increase size of arrays and time
for(ArraySz = STEP_SZ; ArraySz<MAX_ARRAY_SZ; ArraySz += STEP_SZ) {
a = (dbl *)realloc(a, ArraySz * sizeof(dbl));
b = (dbl *)realloc(b, ArraySz * sizeof(dbl));
c = (dbl *)realloc(c, ArraySz * sizeof(dbl));
d = (dbl *)realloc(d, ArraySz * sizeof(dbl));
// Outside the timing loop, initialize
// b and d arrays to 1.0 sec for consistent += performance.
memcpy((void *)b, (void *)InitToOnes, ArraySz * sizeof(dbl));
memcpy((void *)d, (void *)InitToOnes, ArraySz * sizeof(dbl));
start = clock();
for(i = LoopKnt; i; i--) {
for(j = ArraySz; j; j--) {
a[j] += b[j];
c[j] += d[j];
}
}
Cumulative_Combined += (clock()-start);
printf("\n %6i miliseconds for combined array sizes %i and %i loops",
(int)(clock()-start), ArraySz, LoopKnt);
start = clock();
for(i = LoopKnt; i; i--) {
for(j = ArraySz; j; j--) {
a[j] += b[j];
}
for(j = ArraySz; j; j--) {
c[j] += d[j];
}
}
Cumulative_Separate += (clock()-start);
printf("\n %6i miliseconds for separate array sizes %i and %i loops \n",
(int)(clock()-start), ArraySz, LoopKnt);
}
printf("\n Cumulative combined array processing took %10.3f seconds",
(dbl)(Cumulative_Combined/(dbl)CLOCKS_PER_SEC));
printf("\n Cumulative seperate array processing took %10.3f seconds",
(dbl)(Cumulative_Separate/(dbl)CLOCKS_PER_SEC));
getchar();
free(a); free(b); free(c); free(d); free(InitToOnes);
return 0;
}
Ich bin mir nicht sicher, warum entschieden wurde, dass MFLOPS eine relevante Metrik ist. Ich dachte, die Idee sei, mich auf Speicherzugriffe zu konzentrieren, also habe ich versucht, die Gleitkomma-Rechenzeit zu minimieren. Ich bin im +=
abgereist, bin mir aber nicht sicher warum.
Eine direkte Zuweisung ohne Berechnung wäre ein sauberer Test der Speicherzugriffszeit und würde einen Test erzeugen, der unabhängig von der Schleifenzahl einheitlich ist. Vielleicht habe ich etwas im Gespräch verpasst, aber es lohnt sich, zweimal darüber nachzudenken. Wird das Plus aus der Zuordnung herausgerechnet, ist die kumulierte Zeit mit jeweils 31 Sekunden nahezu identisch.
Dies liegt daran, dass die CPU nicht so viele Cache-Fehler aufweist (und warten muss, bis die Array-Daten von den RAM Chips stammen). Es wäre interessant für Sie, die Größe der Arrays kontinuierlich anzupassen, damit Sie die Größen des Level 1-Cache (L1) und dann des Level 2-Cache (überschreiten. L2) Ihrer CPU und zeichnen Sie die Ausführungszeit Ihres Codes in Abhängigkeit von der Größe der Arrays auf. Das Diagramm sollte keine gerade Linie sein, wie Sie es erwarten würden.
Die erste Schleife schreibt abwechselnd in jede Variable. Die zweiten und dritten machen nur kleine Sprünge von Elementgröße.
Versuchen Sie, zwei parallele Linien von 20 Kreuzen mit einem Stift und Papier in einem Abstand von 20 cm zu schreiben. Versuchen Sie einmal, die eine und dann die andere Zeile zu beenden, und versuchen Sie es ein anderes Mal, indem Sie abwechselnd ein Kreuz in jede Zeile schreiben.
Es kann altes C++ und Optimierungen sein. Auf meinem Computer habe ich fast die gleiche Geschwindigkeit erhalten:
Eine Schleife: 1,577 ms
Zwei Schleifen: 1,507 ms
Ich führe Visual Studio 2015 auf einem E5-1620 3,5-GHz-Prozessor mit 16 GB RAM aus.