почему поэлементное сложение намного быстрее в отдельных циклах, чем в комбинированном цикле?

<p>предположим, что <code>a1</code>, <code>b1</code>, <code>c1</code> и <code>d1</code> указывают на память кучи, и мой числовой код имеет следующий основной цикл.</p><pre><code>const int n = 100000; for (int j = 0; j < n; j++) { a1[j] += b1[j]; c1[j] += d1[j]; } </code></pre><p>этот цикл выполняется 10000 раз через другой внешний цикл <code>for</code>. чтобы ускорить его, я изменил код на:</p><pre><code>for (int j = 0; j < n; j++) { a1[j] += b1[j]; } for (int j = 0; j < n; j++) { c1[j] += d1[j]; } </code></pre><p>скомпилировано на ms <a href="http://en.wikipedia.org/wiki/visual\_c++#32-bit\_versions">visual c++ 10.</a> с полной оптимизацией и <a href="http://en.wikipedia.org/wiki/sse2">sse2</a> включено для 32-разрядных на <a href="http://en.wikipedia.org/wiki/intel\_core\_2">intel core 2</a> duo (x64), первый пример занимает 5,5 секунды, а пример с двойной петлей - всего 1,9 секунды. мой вопрос: (пожалуйста, обратитесь к моему перефразированному вопросу внизу)</p><p>ps: я не уверен, если это поможет:</p><p>разборка для первого цикла в основном выглядит следующим образом (этот блок повторяется примерно пять раз в полной программе):</p><pre><code>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] </code></pre><p>каждый цикл в примере с двойным циклом создает этот код (следующий блок повторяется примерно три раза):</p><pre><code>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 </code></pre><p>вопрос оказался неактуальным, так как поведение сильно зависит от размеров массивов (n) и кэша цп. так что, если есть дальнейший интерес, я перефразирую вопрос:</p><p><strong> не могли бы вы дать четкое представление о деталях, которые приводят к разным поведениям кэша, как показано пятью областями на следующем графике? </strong></p><p><strong> также было бы интересно указать на различия между архитектурами цп и кеша, предоставив аналогичный график для этих цп. </strong></p><p>pps: вот полный код. он использует <a href="https://www.threadingbuildingblocks.org/">tbb</a> <code>tick\_count</code> для синхронизации с более высоким разрешением, которую можно отключить, не задав макрос <code>tbb\_timing</code>:</p><pre><code>#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; } } </code></pre><p>(показывает flop/s для разных значений <code>n</code>.)</p><p><img src="https://i.stack.imgur.com/keuwu.gif" alt="enter image description here"></p>

<p>после дальнейшего анализа этого, я полагаю, это (по крайней мере, частично) вызвано выравниванием данных четырех указателей. это приведет к некоторому уровню конфликтов банка/пути кеша.</p><p>если я правильно угадал, как вы размещаете свои массивы, они <strong><em>, скорее всего, будут выровнены по строке страницы </em></strong>.</p><p>это означает, что все ваши обращения в каждом цикле будут попадать в один и тот же путь кеша. тем не менее, процессоры intel некоторое время имели 8-стороннюю ассоциативность l1-кэша. но на самом деле производительность не совсем одинакова. доступ к 4-сторонним каналам все еще медленнее, чем, скажем, к 2-сторонним.</p><p><strong>edit: на самом деле выглядит так, будто вы распределяете все массивы отдельно.</strong> обычно, когда запрашиваются такие большие выделения, распределитель запрашивает свежие страницы из ос. следовательно, существует высокая вероятность того, что большие выделения появятся с тем же смещением от границы страницы.</p><p><strong>вот тестовый код:</strong></p><pre><code>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; } </code></pre><hr><p><strong>результаты тестов:</strong></p><h1>правка: результаты на <em> фактической </em> машине с архитектурой core 2:</h1><p><strong>2 x intel xeon x5482 harpertown @ 3,2 ггц:</strong></p><pre><code>#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 </code></pre><p>замечания:</p><ul> <li> <p><strong>6,206 секунд</strong> с одним циклом и <strong>2,116 секунд</strong> с двумя циклами. это точно воспроизводит результаты оп.</p> </li> <li> <p><strong>в первых двух тестах массивы выделяются отдельно.</strong> вы заметите, что все они имеют одинаковое выравнивание относительно страницы.</p> </li> <li> <p><strong>во вторых двух тестах массивы упакованы вместе, чтобы нарушить это выравнивание.</strong> здесь вы заметите, что оба цикла быстрее. кроме того, второй (двойной) цикл теперь стал медленнее, чем вы обычно ожидаете.</p> </li> </ul><p>как отмечает @stephen cannon в комментариях, очень вероятно, что это выравнивание приведет к <strong><em> ложному псевдониму </em></strong> в блоках загрузки/хранения или в кэше. я гуглил по этому поводу и обнаружил, что у intel действительно есть аппаратный счетчик для <strong><em> частичного псевдонима адреса </em></strong> киосков:</p><p> <a href="http://software.intel.com/sites/products/documentation/doclib/stdxe/2013/~amplifierxe/pmw\_dp/events/partial\_address\_alias.html">http://software.intel.com/sites/products/documentation/doclib/stdxe/2013/~amplifierxe/pmw\_dp/events/partial\_address\_alias.html</a> </p><hr><h1>5 регионов - объяснения</h1><p><strong>регион 1:</strong></p><p>это легко. набор данных настолько мал, что в производительности преобладают издержки, такие как циклы и ветвления.</p><p><strong>регион 2:</strong></p><p><strike>здесь при увеличении размеров данных количество относительных накладных расходов уменьшается, а производительность "насыщается". здесь два цикла медленнее, потому что он имеет в два раза больше циклов и ветвлений.</strike></p><p>я не уверен, что именно здесь происходит ... выравнивание все еще может играть эффект, как упоминает агнер фог <a href="http://www.agner.org/optimize/blog/read.php?i=142">конфликты банков кэша</a> . (эта ссылка о sandy bridge, но идея должна быть применима и к core 2.)</p><p><strong>регион 3:</strong></p><p>на этом этапе данные больше не помещаются в кэш l1. таким образом, производительность ограничена пропускной способностью кэша l1 <-> l2.</p><p><strong>регион 4:</strong></p><p>мы наблюдаем падение производительности в одном цикле. и, как уже упоминалось, это связано с выравниванием, которое (наиболее вероятно) вызывает <strong><em> ложный псевдоним </em></strong> останавливает блоки загрузки/сохранения процессора.</p><p>однако для того, чтобы произошло ложное наложение, между наборами данных должен быть достаточно большой шаг. вот почему вы не видите этого в регионе 3.</p><p><strong>регион 5:</strong></p><p>на данный момент в кеш ничего не помещается. таким образом, вы ограничены пропускной способностью памяти.</p><hr><p><img src="https://i.stack.imgur.com/elcgl.png" alt="2 x intel x5482 harpertown @ 3.2 ghz"> <img src="https://i.stack.imgur.com/qmpwj.png" alt="intel core i7 870 @ 2.8 ghz"> <img src="https://i.stack.imgur.com/npyhg.png" alt="intel core i7 2600k @ 4.4 ghz"></p>

<p>ок, правильный ответ определенно должен что-то делать с кешем процессора. но использовать аргумент кеша довольно сложно, особенно без данных.</p><p>есть много ответов, которые привели к большому обсуждению, но давайте посмотрим правде в глаза: проблемы с кэшем могут быть очень сложными и не одномерными. они сильно зависят от размера данных, поэтому мой вопрос был несправедливым: он оказался в очень интересном месте на графике кеша.</p><p>ответ @ mysticial убедил многих людей (в том числе и меня), вероятно, потому, что он был единственным, кто, казалось, полагался на факты, но это была только одна "точка данных" правды.</p><p>вот почему я объединил его тест (используя непрерывное или раздельное распределение) и совет ответчика @james.</p><p>приведенные ниже графики показывают, что большинство ответов и особенно большинство комментариев к вопросу и ответам можно считать совершенно неправильными или верными в зависимости от конкретного сценария и используемых параметров.</p><p>обратите внимание, что мой первоначальный вопрос был по адресу <strong>n = 100.0</strong>. эта точка (случайно) демонстрирует особое поведение:</p><ol> <li> <p>он обладает наибольшим расхождением между версией с одним и двумя циклами (почти в три раза)</p> </li> <li> <p>это единственная точка, где однопетлевая (а именно с непрерывным распределением) превосходит двухконтурную версию. (это сделало возможным ответ mysticial.)</p> </li> </ol><p>результат с использованием инициализированных данных:</p><p><img src="https://i.stack.imgur.com/orxf8.png" alt="enter image description here"></p><p>результат с использованием неинициализированных данных (это то, что проверил mysticial):</p><p><img src="https://i.stack.imgur.com/mzph9.png" alt="enter image description here"></p><p>и это трудно объяснить: инициализированные данные, которые выделяются один раз и используются повторно для каждого следующего контрольного примера с разным размером вектора:</p><p><img src="https://i.stack.imgur.com/aidfv.png" alt="enter image description here"></p><h2>предложение</h2><p>каждый вопрос низкого уровня производительности, связанный с переполнением стека, должен требовать предоставления информации mflops для всего диапазона размеров данных, связанных с кэшем! это пустая трата времени, чтобы думать об ответах и особенно обсуждать их с другими без этой информации.</p>

<p>второй цикл включает в себя гораздо меньшую активность кеша, поэтому процессору легче справляться с требованиями к памяти.</p>

<p>представьте, что вы работаете на машине, где <code>n</code> было просто правильным значением для того, чтобы можно было одновременно хранить два ваших массива в памяти, но общего объема доступной памяти посредством кэширования на диске все еще было достаточно для хранения всех четырех.</p><p>предполагая простую политику кэширования lifo, этот код:</p><pre><code>for(int j=0;j<n;j++){ a[j] += b[j]; } for(int j=0;j<n;j++){ c[j] += d[j]; } </code></pre><p>сначала приведет к загрузке <code>a</code> и <code>b</code> в ram, а затем будет полностью обработан в ram. когда начинается второй цикл, <code>c</code> и <code>d</code> будут загружены с диска в ram и запущены.</p><p>другой цикл</p><pre><code>for(int j=0;j<n;j++){ a[j] += b[j]; c[j] += d[j]; } </code></pre><p>выведет на экран два массива и страницу в двух других <strong>каждый раз в цикле</strong>. очевидно, это будет <strong>намного</strong> медленнее.</p><p>вы, вероятно, не видите кеширование диска в своих тестах, но вы, вероятно, видите побочные эффекты какой-либо другой формы кеширования.</p><hr><p>кажется, здесь есть небольшая путаница/недоразумение, поэтому я попытаюсь немного уточнить на примере.</p><p>скажите <code>n = 2</code>, и мы работаем с байтами. таким образом, в моем сценарии мы имеем <strong>всего 4 байта озу</strong>, а остальная часть нашей памяти значительно медленнее (скажем, в 100 раз больше доступа).</p><p>предполагая довольно тупую политику кэширования <em>, если байт не находится в кеше, поместите его туда и получите следующий байт, пока мы на нем </em>, вы будете получить сценарий примерно так:</p><ul> <li> <p>с</p> <pre><code>for(int j=0;j<n;j++){ a[j] += b[j]; } for(int j=0;j<n;j++){ c[j] += d[j]; } </code></pre></li> <li> <p>кэшируйте <code>a[0]</code> и <code>a[1]</code>, затем <code>b[0]</code> и <code>b[1]</code> и установите <code>a[0] = a[0] + b[0]</code> в кэш - теперь в кэше четыре байта, <code>a[0], a[1]</code> и <code>b[0], b[1]</code>. стоимость = 100 + 100.</p> </li> <li>установить <code>a[1] = a[1] + b[1]</code> в кеш. стоимость = 1 + 1.</li> <li>повторите эти действия для <code>c</code> и <code>d</code>.</li> <li> <p>общая стоимость = <code>(100 + 100 + 1 + 1) * 2 = 404</code></p> </li> <li> <p>с</p> <pre><code>for(int j=0;j<n;j++){ a[j] += b[j]; c[j] += d[j]; } </code></pre></li> <li> <p>кэшируйте <code>a[0]</code> и <code>a[1]</code>, затем <code>b[0]</code> и <code>b[1]</code> и установите <code>a[0] = a[0] + b[0]</code> в кэш - теперь в кэше четыре байта, <code>a[0], a[1]</code> и <code>b[0], b[1]</code>. стоимость = 100 + 100.</p> </li> <li>извлеките <code>a[0], a[1], b[0], b[1]</code> из кеша и кеша <code>c[0]</code> и <code>c[1]</code>, затем <code>d[0]</code> и <code>d[1]</code> и установите <code>c[0] = c[0] + d[0]</code> в кеш. стоимость = 100 + 100.</li> <li>я подозреваю, что вы начинаете видеть, куда я иду.</li> <li>общая стоимость = <code>(100 + 100 + 100 + 100) * 2 = 800</code></li> </ul><p>это классический сценарий трэша кеша.</p>

<p>это не из-за другого кода, а из-за кеширования: ram медленнее, чем регистры процессора, а кэш-память находится внутри процессора, чтобы избежать записи ram каждый раз, когда переменная меняется. но кеш не такой большой, как ram, следовательно, он отображает только его часть.</p><p>первый код изменяет адреса удаленной памяти, чередуя их в каждом цикле, тем самым постоянно требуя аннулировать кэш.</p><p>второй код не чередуется: он просто передается по соседним адресам дважды. это заставляет всю работу завершаться в кэше, аннулируя ее только после запуска второго цикла.</p>

<p><strong> я не могу воспроизвести результаты, обсуждаемые здесь. </strong></p><p>я не знаю, виноват ли плохой тестовый код или что-то в этом роде, но эти два метода находятся в пределах 10% друг от друга на моей машине, используя следующий код, и один цикл обычно немного быстрее двух - как вы ожидать.</p><p>размеры массивов варьировались от 2 ^ 16 до 2 ^ 24 с использованием восьми циклов. я был осторожен при инициализации исходных массивов, поэтому в назначении <code>+=</code> не запрашивалось <a href="http://en.wikipedia.org/wiki/floating-point\_unit">fpu</a> добавить мусор памяти, интерпретируемый как double.</p><p>я поиграл с различными схемами, такими как помещение <code>b[j]</code>, <code>d[j]</code> в <code>inittozero[j]</code> внутри циклов, а также с использованием <code>+= b[j] = 1</code> и <code>+= d[j] = 1</code>, и я получил довольно последовательные результаты.</p><p>как и следовало ожидать, инициализация <code>b</code> и <code>d</code> внутри цикла с использованием <code>inittozero[j]</code> дала комбинированному подходу преимущество, так как они выполнялись вплотную перед назначениями <code>a</code> и <code>c</code>, но все еще в пределах 10%. пойди разберись.</p><p>аппаратное обеспечение <a href="http://en.wikipedia.org/wiki/dell\_xps#xps\_8500">dell xps 85</a> с поколением 3 <a href="http://en.wikipedia.org/wiki/intel\_core#core\_i7">core i7</a> @ 3,4 ггц и 8 гб памяти. для 2 ^ 16 до 2 ^ 24, используя восемь циклов, совокупное время было 44,987 и 40,965 соответственно. visual c++ 2010, полностью оптимизирован.</p><p>ps: я изменил циклы для обратного отсчета до нуля, и комбинированный метод был немного быстрее. почесывая голову обратите внимание на новый размер массива и количество циклов.</p><pre><code>// 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; } </code></pre><p>я не уверен, почему было решено, что mflops был релевантным показателем. хотя идея заключалась в том, чтобы сосредоточиться на доступе к памяти, поэтому я попытался минимизировать время вычислений с плавающей запятой. я оставил в <code>+=</code>, но я не уверен, почему.</p><p>прямое назначение без вычислений было бы более точным тестом времени доступа к памяти и создало бы тест, который был бы равномерным независимо от числа циклов. может быть, я что-то упустил в разговоре, но об этом стоит подумать дважды. если плюс не входит в задание, то кумулятивное время почти одинаково и составляет 31 секунда.</p>

<p>это связано с тем, что в процессоре не так много промахов кэша (когда ему приходится ждать получения данных массива из чипов ram). было бы интересно непрерывно корректировать размер массивов, чтобы вы превышали размеры <a href="http://en.wikipedia.org/wiki/cpu\_cache#multi-level\_caches">кэш 1-го уровня</a> (l1), а затем <a href="http://en.wikipedia.org/wiki/cpu\_cache#multi-level\_caches">кэш 2-го уровня</a> ( l2) вашего цп и график времени, затраченного на выполнение вашего кода, в зависимости от размеров массивов. график не должен быть прямой линией, как вы ожидаете.</p>

<p>первый цикл чередует запись в каждой переменной. второй и третий делают только небольшие скачки размера элемента.</p><p>попробуйте написать две параллельные линии по 20 крестиков с помощью ручки и бумаги, разделенных на 20 см. попробуйте один раз закончить одну, а затем другую строку и попробуйте другой раз, поочередно написав крестик в каждой строке.</p>

<p>это может быть старый c++ и оптимизации. на моем компьютере я получил почти такую же скорость:</p><p>один цикл: 1,577 мс</p><p>два цикла: 1,507 мс</p><p>я использую visual studio 2015 на процессоре e5-1620 с частотой 3,5 ггц и 16 гб оперативной памяти.</p>