company_banner

Оптимизация для CPU: как найти черную кошку в темной комнате


    Метод недопустимой операции:
    Разделить кошку на ноль,
    после чего она станет бесконечно большой,
    так что её будет невозможно упустить.

    [АбсурдопедиЯ]

    Пытаясь найти проблему с производительностью относительно простого кода, я вспомнил несколько нелепых методов решения, описанных на Абсурдопедии, для задачи поиска черной кошки в темной комнате. Как ни странно, мне очень помогло последовательное использование трех методов, которые можно найти по ссылке: Прагматизм, Метод дихотомии и Метод тыка.

    Итак, имеем задачу последовательной перестановки байтов в каждом слове массива (big-endian <-> little-endian) и суммирования всех слов в одно (reduction). Оставим пока в стороне задачу распараллеливания, ибо ее решение близко к тривиальному, и для нас пока не представляет интереса.

    image


    Прямая (в лоб) имплементация алгоритма обычно называется наивной. Это наверное от того, что мы, программисты, навивно полагаемся на компилятор, который сгенерирует если не оптимальный, то близкий к этому код, и дальше оптимизировать будет уже нечего. Впрочем, как мы увидим ниже в дизассемблере, компилятор производит довольно простую последовательность инструкций, по-идее, не должную вызывать особых проблем с производительностью на платформе x86.

    void init()
    {
    	int i;
    	for (i = 0; i < 1024; i++)
    		buf[i] = i;
    }
    
    unsigned int change_endianess(char *big)
    {
    	char temp;
    	temp = big[0];
    	big[0] = big[3];
    	big[3] = temp;
    	temp = big[1];
    	big[1] = big[2];
    	big[2] = temp;
    	return *(unsigned int *)big;
    }
    
    void reduce()
    {
    	int i, n;
    	unsigned int sum = 0;
    	init();
    	for (n = 0; n < ITER_NUM; n++){//repeat itarations
    		for (i = 0; i < 1024; i++){
    			sum += change_endianess((char *)(buf+i));}}
    	printf ("Sum is %d\n", sum);
    }
    
    main ()
    {
    	reduce();
    }
    


    Как выражаются наши остроумные маркетологи, «оценка производительности без профилировки является гаданием». Поэтому запускаем General Exploration профиль в VTune Amplifier XE для того, чтобы оценить, насколько эффективно этот код выполняется на микроархитектуре Core i7 (codename Nehalem). Не забываем указать опции -Zi и /debug:inline-debug-info для Intel Compiler (Windows), чтобы сохранить отладочную информацию и информацию о строках функций, которые были «заинлайнены» (как правильно сказать по-русски, «inlined»?). Последняя опция очень полезна, так как позволяет VTune Amplifier XE атрибутировать значения счетчиков прямо по строкам вложенной функции. Это новая фича VTune; ее можно и отключать, чтобы результаты отображались только напротив вызова фунции.

    image

    Так как тест отрабатывает довольно быстро (несмотря на выбранное достаточно большое количество итераций), полезно в настройках проекта VTune включить опцию Allow Multiple Runs, что заставит инструмент запускать тест несколько раз, собирая счетчики группами. В противном случае, счетчики будут мультиплексироваться, и при небольшом времени выполнения теста точность может пострадать.

    image

    Из результатов понятно, что ничего не понятно. Видно, что производительность исполнения кода в функции reduce довольно далека от совершенства, так как CPI=1.67 более чем в 6 раз больше оптимального 0.25. Нет, это конечно не значит, что у нас потенциал увеличения производительности кода в 6 раз, но добиться блее низкого CPI на таком простом цикле вполне можно было бы. Однако, почему CPI высок, сказать трудно. Метрика Retire Stalls примерно равна 0.7 и говорит нам о том, что во время завершения выполнения инструкций наблюдаются простои конвеера в 70% тактов. Остальные показатели производительности, судя по результатам собранного профиля, в норме. Более того, зная, что в большинстве случаев причиной проблем производительности бывает подсистема памяти, можно специально запустить Memory Access анализ. Но он покажет тоже самое – проблем с памятью нет. Впрочем, код такой простой, а доступ к памяти такой последовательный, что запускать анализ памяти даже и не стоило. По тем же причинам нам не поможет и Branch Analysis.

    Начинаем искать черную кошку в темной комнате, вернее причину провала производительности в микроархитектуре процессора.

    Применяем улучшенный метод поиска кошки «Прагматизм». Изначально необходимо определиться, что мы получим, если найдем эту кошку, и стоит ли ее из-за этого искать. Какой именно у нас потенциал, помогают оценить метрики Execution Stalls и Retire Stalls.

    Первая мертика Execution Stalls — соотношение между значениями счетчиков UOPS_EXECUTED.CORE_STALL_CYCLES и UOPS_EXECUTED.CORE_ACTIVE_CYCLES.
    Счетчик событий UOPS_EXECUTED.CORE_STALL_CYCLES измеряет количество тактов простоя в вычислительной части конвейера процессора, когда диспетчер операций (Reservation Station) не назначает выполнение операций ни на один порт.

    Счетчик событий UOPS_EXECUTED.CORE_ACTIVE_CYCLES соответственно считает циклы, когда хотя бы в одном из четырех вычислительных портов и двух портов чтения/записи есть микрооперация для исполнения.
    Соотношение UOPS_EXECUTED.CORE_STALL_CYCLES / (UOPS_EXECUTED.CORE_ACTIVE_CYCLES + UOPS_EXECUTED.CORE_STALL_CYCLES) позволит нам судить о том, какая часть времени выполнения функции соответствовала простою вычислителя, и каков наш потенциал увеличения производительноти, если мы каким-либо способом добъемся снижения времени простоя процессора.

    Кстати, сумма эти счетчиков должна давать значение счетчика всех тактов CPU_CLK_UNHALTED.THREAD. И в VTune для автоматического вычисления метрики Execution Stalls используется соотношение UOPS_EXECUTED.CORE_STALL_CYCLES / CPU_CLK_UNHALTED.THREAD.
    В нашем случае, соотношение примерно равно 0.24 (четверть тактов простоя из общего числа), и это значит, что, если устранить простои, то потенциально можем увеличить пропускную способность вычислителя по крайней мере на четверть – и это только для одного из четырех слотов.

    Вторая метрика Retire Stalls – это соотношение между значениями UOPS_RETIRED.STALL_CYCLES / CPU_CLK_UNHALTED.THREAD, показывающая какая часть времени от общего количества тактов соответствует состоянию, когда ни одна микрооперация не завершается (retired), хотя теоретически, могло бы завершаться до 4 операций за такт.
    Это значит, что при 70% простоя, если мы хорошо будем искать кошку, устранив причину ожидания исполнения операций, можно надеяться на ускорение выполнения программы по крайней мере в 2.3 раза.
    Пользуясь здравым смыслом, делаем вывод, что ради такого ускорения выполнения алгорима, можно и попытаться.

    Чтобы найти, где у нас проблема, используем второй метод поиска кошки — «Метод дихотомии»: последовательно делим комнату на равные части, вернее разделяем стадии конвеера процессора на логические части, где можно пытаться искать причину проблемы. Этот метод хорошо описан в Intel® 64 and IA-32 Architectures Optimization Reference Manual, в разделе “Using Performance Monitoring Events” как «Performance Events Drill-Down and Software Tuning Feedback Loop”. Вот его несколько упрощенное представление:

    image

    Суть метода состоит в том, чтобы последовательно отделять части конвеера, находя с помощью соответствующих счетчиков место, где происходит его простой, то есть незанятость продвижением микроопераций (bottleneck). Первоначально необходимо определить, выполняются ли микроинструкции в конвеере. Если да, то заканчивается ли их выполнение успехом (Retire). В случае выполнения незаканчивающихся изменением состояния процессора операций (Non-Retired), имеет место ошибочное предсказание и выполнение не тех ветвлений программы (Bad Speculation). В нашем же случае мы имеем дело с простоем конвеера, и далее необходимо померить, возникают ли проблемы с выделением ресурсов процессора (внутренние регистры, буферы, и т.д.). Если нет, то это будет значить, что процессору не хватает инструкций для исполнения, и проблема во Frond-End, то есть там, где инструкции выбираются из кэша инструкций, декодирутся, и ставятся в очередь исполненя. Если да, то проблема где-то в самой темной части комнаты, называемой Back-End, где производятся вычисления, и где могут быть проблемы как с самими вычислителями, так и с диспетчеризацией микроинструкций, выделением буферов, ожиданием данных из памяти, и так далее.

    Определить, есть ли Allocation Stalls очень просто. Нужно посмотреть показания счетчика с именем RESOURCE_STALLS.ANY. Переключив viewpoint VTune на Hardvare Event Count и глядя на показания этого счетчика (значение, соизмеримое с общим количествои тактов CPU), понимаем, что именно нехватка каких-то ресурсов, согласно счетчику, может идентифицировать проблему в нашем коде.

    Согласно Абсурдопедии, Метод дихотомии должен привести к делению комнаты до тех пор, пока кошка не сконцентрируется в точку. Однако, для микроархитектуры процессора возможности метода не безграничны и на определенном этапе заканчиваются. И здесь, как нельзя лучше, применяется «Метод тыка» – один из самых эффективных научных методов, в случаях когда другие, более строгие и регулярные методы, уже не помогают.
    Нам необходимо перемерить все возможные счетчики, которые харектеризуют нехватку ресурсов. Их немного, и нужно всего лишь заменить расширение счетчика ANY, на конкретные ресурсы. Для моего мобильного варианта Core i7: RESOURCE_STALLS.LOAD, RESOURCE_STALLS.STORE, RESOURCE_STALLS.ROB_FULL, RESOURCE_STALLS.RS_FULL, RESOURCE_STALLS.OTHER эти данные можно собрать отдельно, а можно и найти в результатах профиля General Exploration. Суммарные показатели намекают нам, что существует проблема остановки продвижения конвеера из-заполнения ROB-буфера, и нехватки буфера записи данных.

    image

    В Store Buffer складываются данные для записи в память. Задержки выполнения операций будут наблюдаться, если следует много инструкций записи, перемежающихся с чтением. Reorder Buffer (ROB) содержит в себе микрооперации, которые ожидают окончания исполнения, а также выполненые операции, которые изменяют состояние процессора в порядке их исполнения (in-order). Важно определить, при ожидании какой инструкции буфер был переполнен поступающими микрооперациями и блокировал дальнейшую работу конвеера.

    Здесь нам поможет Source View вместе с дизассемблированием — Assembly View. Для экономии места расположим их отдельно, хотя в VTune они находятся рядом.

    image

    Как видно по значению счетчика CPU_CLK_UNHALTED.TREAD, большинство тактов попало на цикл выполнения суммирования слов, а не на функцию перестановки. Смещение данных на строку цикла for несколько запутывает, но это всегда происходит с циклами. Результаты, относящиеся к телу цикла могут сместиться на его заголовок, если тело цикла небольшое. Чтобы убедиться в этом, посмотрим на дизассемблированный вариант функции.

    image

    Интересным для нас является Basic Block 5: он представляет собой цикл выполнения операции перестановки байтов и суммирования слов – так сгенерировал инструкции компилятор. Начиная с адреса 0х4010d0 по 0x401105 находятся инструкции перестановки байтов в слове. По адресу 0х40110с – как раз искомая инструкция суммирования значения слова в регистре edx. Опять же, за счет смещения (skid) результаты отобразились на инструкции inc ниже, которая не может занимать столько тактов. Что интересно, эта же инструкция суммирования add собрала наибольшее количество показаний счетчиков Resource Stalls. Забегая вперед и применяя все тот же Метот тыка, отметим, что убрав из комнаты кошку, то есть эту операцию суммирования, мы точно узнаем, что комната пуста, а проблемы с Resource Stalls и производительностью исчезли.

    Так в чем же проблема с этой инструкцией, вернее с несколькими микрооперациями, из которых она состоит (чтение адреса, чтение данных из памяти и операция суммирования в регистре)? Тут, как всегда есть одна хитрость: нужно либо хорошо знать особенности реализации выгрузки-загрузки данных в микроархитектуре, либо использовать несколько другой микропроцессор. Вместо нашего мобильного Core i7 (code name Nehalem) померяем на платформе c процессором Xeon либо на процессоре с кодовым именем Westmere (так сказать, возьмем комнату пооснасщеннее), в которых дополнительно можно получить счетчик LOAD_BLOCK.OVERLAP_STORE. Он будет просто зашкаливать на asm-инструкции add. Вообще этот счетчик сигнализирует о заблокированных по разным причинам микрооперациях загрузки данных (load). Самая распространенная причина такой сигнализации состоит в том, что микрооперация load заблокирована предыдущей микрооперацией записи (store):
    — Некоторые байты читаемых данных были в предыдущей операции записи, а некоторые нет;
    — Байты слова чтения те же, что и для предшествующей записи, и слово записи выровнено по своему размеру, при этом читаются 1-2 байта не выровненные по началу слова записи, либо 4-8 байт, и не выровненные с данными записи;
    — Байты слова чтения те же, что и для предшествующей записи, но слово записи не выровнено по своему размеру, и чтение не выровнено по началу слова записи.

    Теперь присмотримся внимательнее к последовательности asm-инструкций, реализующих цикл перестановки байт и сумиирования. Здесь происходит ни что иное, как чтение слова с соответствующим побайтовым смещением из памяти в регистры ebx и ecx (временные значения) и побайтовая запись данных из нижней части регистров bl и cl в память. Пока мы побайтно читаем и пишем в память, никаких проблем нет. Однако, стоит нам попытаться загрузить целое слово из памяти (микрооперация load в инструкции add) по тому же адресу слова, что в предыдущей операции записи, как эта операция загрузки будет заблокирована. Процессор не может передать все слово целиком в буфер чтения сразу после записи этого слова по частям – приходится ждать. Такая проблема называется Blocked Loads Due to No Store Forwarding. Запомните ее, так как она будет часто встречаться, если вы активно работаете с байтами внутри слов.

    Необходимо отметить, что, исследуя проблему на данной мобильной платформе, пришлось применять Модифицированный Метод тыка, так как собранные счетчики явно не говорили о наличии No Store Forwarding. Мы притянули сюда shrink-версию микроархитекуры Nehalem – Westmere, либо Xeon; а профиль General Exploration не содержал счетчика событий LOAD_BLOCK.OVERLAP_STORE. Следующее поколение процессоров — 2nd generation Intel® Core™ processor с кодовым именем SandyBridge (собственно уже являющееся текущим поколением) — содержит специальный счетчик LD_BLOCKS_STORE_FORWARD, который есть в VTune-профиле General Exploration для SandyBridge, и который напрямую укажет нам на проблему.

    Теперь осталось подумать, как исправить код и решить проблему производительности. Из описания причин ее возникновения понятно, что нужно сделать так, чтобы загрузка слова из памяти непосредственно не следовала после его побайтной записи по тому же адрусу. Значит, чтобы избежать задач поиска «этого котэ» в будущем, нужно разделить комнату и кошку: вынесем операцию суммирования слов массива в отдельный цикл.

    void reduce()
    {
    	int i, n;
    	unsigned int sum = 0;
    	init();
    
    	for (n = 0; n < ITER_NUM; n++)
    	{
    		for (i = 0; i < 1024; i++)
    			change_endianess((char *)(buf+i));
    		for (i = 0; i < 1024; i++)
    			sum += buf [i];
    	}
    	printf ("Sum is %d\n", sum);
    }
    


    Разницу времени выполнения программы можно видеть, сравнив результаты профилировки.

    image

    Остался один небольшой вопрос, почему функция ускорилась более чем в 3 раза, тогда как в начале мы определили, что циклов простоя всего 70 процентов? Дело в том, что такое количество циклов процессор простаивает при данном наборе инструкций, использованных компилятором. Разделив в исходном коде циклы перестановки байтов и суммирования, мы помогли компилятору автоматически векторизовать последний цикл, и он заменил обычные скалярные инструкции суммирования и чтения-записи на векторные SIMD-инструкции, которые на 32-битной платформе выполняются в 4 раза быстрее для данных типа int32. Помните потенциал, посчитанный по CPI?
    Intel
    Company

    Comments 36

      +8
      Многое непонятно, но выглядит круто!
        +3
        В данной конкретно задаче вместо огромного количества перестановок, которые превращаются в изнасилование регистров полезнее было бы использовать asm инструкцию bswap, сократить количество циклов и вместо обращения buf+i обращаться к buf, и потом делать buf++. Все это для начала, конечно )
          +2
          Спасибо за замечание. Можно, наверное, посвятить еще один обзор-исследование, почему этого не сделал компилятор :). Но суть поста была не совсем в том, как эффективно решить эту задачу «вручную», а как понять, что нагенерил компилятор и в чем проблемы исполнения этого кода.
        • UFO just landed and posted this here
            +1
            Ух ты, а откуда такая терминология?
            • UFO just landed and posted this here
                +4
                Неа, не понравилось.
              +2
              Когда подбирают проверочное слово, то говорят про корень слова, а не суффиксы, а корень в обоих случаях один — лин. </gramanaci>
              • UFO just landed and posted this here
                0
                Самое близкое русское слово к «inline» в данном контексте, мне кажется, что «внутритекстовый».
                Но имхо лучше вообще не переводить.
                  +9
                  Самое близкое — встраиваемый
                  inline function = встраиваемая функция
                    0
                    Пожалуй, вы правы.
                      0
                      Да, тоже соглашусь, что «встраиваемая» — наиболее подходящий термин, я просто о нем совсем забыл. Хотя оборот «функция была встроена компилятором» звучит как-то не очень. Может быть «вставлена» или «подставлена»…
                        0
                        А чем вам не нравится «встроена компилятором»?

                        встроена = перенесена внутрь чего-либо (в данном случае внутрь другой функции)

                        по сравнению с:

                        вставлена — она и так вставлена — не передает суть того, что именно тело функции встроилось в другую функцию

                        подставлена — это скорее к ситуации, когда вместо цикла
                        for( int i = 0; i < N;++i ) *p++ = 0;
                        вставляется memset, т.е. подстановка намекает на «применение частного случая», достаточно вспомнить математику: подставим х =…
                          0
                          «Подставлена», «раскрыта», «развернута»…
                  0
                  Почему компилятор настолько тупой, что при суммировании пытается заново загрузить значение из памяти, а не использует возвращённое значение функции? И почему для этапа перестановки не загружает всё в один регистр, чтобы потом одним махов выгрузить dword, а насилует память 4мя сохранениями char?
                  Компиляторы ещё не настолько умны? Как выглядел бы C код с учётом исправления перечисленных выше проблем?
                  Говоря в VisualC есть ещё дико оптимизированная инструкция bswap, заодно и с ней сравнение неплохо бы увидеть.
                    +1
                    Компилятор пытается загрузить значение из памяти просто чтобы его получить — то, что 4 char образуют один unsigned int он почему-то не сообразил. Почему он не смог хотя бы достать big[0] и big[1] за одно обращение (и использовать потом bh и bl) — тем более непонятно.

                    Код на регистрах мог бы выглядеть так:
                    unsigned int chend2(unsigned int p){
                    	unsigned int res=p&0xff;
                    	p>>=8;	res=(res<<8)|(p&0xff);
                    	p>>=8;	res=(res<<8)|(p&0xff);
                    	p>>=8;	res=(res<<8)|p;
                    	return res;
                    }
                    
                      0
                      Почему компилятор настолько тупой, что при суммировании пытается заново загрузить значение из памяти, а не использует возвращённое значение функции? И почему для этапа перестановки не загружает всё в один регистр, чтобы потом одним махов выгрузить dword, а насилует память 4мя сохранениями char?
                      Компиляторы ещё не настолько умны?

                      Я не берусь судить о том, на сколько туп компилятор, но (забыв о типах данных) насилия над памятью тут нет. Да, значение, которое получено в (встроенной) функции выгружено в память, а для суммирования выполняется загрузка по тому же адресу. Но, процессор использует store buffer чтобы вернуть выгруженные данные для вычисления, совершенно не ожидая их загрузки.
                      Распознавать последовательности записи и чтения невыровненных данных по одному адресу, по-видимому, компилятор пока не умеет.
                        +1
                        Компиляторы ещё не настолько умны?

                        В какой-то момент я попробовал скомпилировать порядка сотни самых простых кусков кода строк по 5 одним и тем же коммерческим компилятором.

                        Результаты совершенно произвольные. Где-то компилятор все понимает и оптимизирует до упора — вплоть до полного удаления бессмысленного кода. Где-то он генерирует настолько тупой и прямолинейный код, что создается впечатление, что там оптимизатора нет вообще.

                        Объяснение разработчиков компилятора — компилятор «не обучен» выявлять такие куски. Там нет умного искусственного интеллекта со здравым смыслом на все случаи жизни, там просто набор шаблонных правил — образцов ситуаций и действий по оптимизации на эти ситуации. Ну и плюс в оптимизаторе тоже есть ошибки — не все задуманные оптимизации не всегда срабатывают.
                        +1
                        Серьезное исследование. Правда, первое, что мне пришло в голову — unsigned int chend(unsigned int p){
                        return CHH[p&0xffff]|CHL[p>>16];
                        }

                        где массивы CHH и CHL заполняются заранее — работает примерно в 1.5 раза быстрее, чем перестановка и последующее суммирование.
                          0
                          Если честно проблема и решение целиком высосаны из пальца.

                          Первое что приходит в голову, работает больше чем в 3 раза быстрее и без разделения циклов.

                          unsigned int change_endianess(unsigned int *big)
                          {
                            unsigned int t = *big;
                            unsigned int r = ((t&0xFF)<<24)|((t&0xFF00)<<8)|((t&0xFF0000)>>8)|((t&0xFF000000)>>24);
                            *big = r;
                            return r;
                          }
                            +5
                            Если честно, то Вы, кажется, не поняли сути проблемы. Речь шла не о том, как написать максимально быструю имплементацию простого алгоритми, а о том, как, используя профилировщик, определить существование провала производительности и понять причину его возникновения. А как исправить, это уже совсем другая история. Мой вариант был приведен только для того, чтобы отделить проблемное чтение данных от функции перестановки в которой производилась запись.
                            Естественно, в Вашем варианте перестановка будет сделана в регистрах, и суммирование тоже будет в регистре. Однако, это решение подходит для моего рафинированного примера, который был редуцирован из несколько более сложного для наглядности. В реальном примере все было не так очевидно, но его приводить — значит загромождать пост длинными листингами.
                              +1
                              Мне понятна идея. Но пример рафинирован настолько, что стал тривиальным и скучным. Не интересно смотреть использование профилировщика там где и так всё ясно. Любой кто читал документацию по оптимизации знает по ограничения store-to-load (еще со времен Pentium 4, а то и раньше).

                              Было бы интересно посмотреть пример, где без профилировщика реально не разобраться.

                              PS Я не настаиваю что мой вариант самый быстрый, но в данном случае он должен быть первым «наивным» вариантом. Т.к. преобразовывать int* в char* чтобы байты переставить это через чур.

                                +3
                                Это всегда компромисс — выбор между простым, понятным примером, но скучным кому-то, и мучительным, интересным, но требующим для его пояснения многостраничного текста, а для понимания — серьезных усилий.
                                Ну и замечу, что документация по процессорной оптимизации, далеко не легкое чтиво, и эти мануалы не являются настольными книгами программистов. Причины этого понятны и объяснимы.
                                  +2
                                  Согласен, соблюсти баланс и угодить всем трудно.

                                  Можно взять какую-нибудь популярную (или не очень) open-source программу в качестве подопытной. Будет живой пример как искать хот-споты и оптимизировать программу в целом.
                                    0
                                    Для этого программа должна быть действительно интересной, и все-равно, все занимательные случаи в ней вряд-ли будут. А те случаи, которые я рассматриваю, мы редуцируем из реальных приложений, которые вряд ли можно публично рассматривать.
                              0
                              У меня получилось, что оно всего на 15% быстрее, чем вариант с разделением (2.0 нс на элемент против 2.3 нс) — а никак не в три раза. Если результат не возвращать в массив, можно выиграть еще 7% (1.86 нс).
                                0
                                В три раза по сравнению с исходным «плохим». Вариант с разделением тоже в три раза быстрее. В итоге они примерно равны (мой и с разделением).
                                0
                                Но, все равно спасибо за Вашу имплементацию.
                                Кстати, у меня она работает только в 2 раза быстрее.
                                –1
                                Оптимальный код:

                                [code]
                                for (i = 0; i < 1024; i++){
                                sum += _byteswap_ulong(*(unsigned long*)(buf+i));
                                [/code]
                                Юзайте интринсики господа.
                                  0
                                  При чтении комментариев постоянно вспоминаю Буратино. Там, где его Мальвина учила математике:
                                  "-У вас в кармане два яблока… — Врете, ни одного…-Я говорю, предположим, у вас в кармане два яблока. Некто взял у вас одно яблоко. Сколько у вас осталось яблок?
                                  -Два… -Почему? — БУРАТИНО: Я же не отдам Некту яблоко, хоть он дерись! "

                                  Так и здесь — люди обсуждают учебный пример.
                                    0
                                    Ничего страшного, может стоит обратить внимание, и предложить хаброжителям интересные примеры алгоритмов для оптимизации?
                                    0
                                    Вот что значит настоящий customer orientation :) У Мальвины его не было.
                                    А интересные примеры алгоритмов — да, будем думать.
                                      +1
                                      Эх, а когда-то только такты надо было посчитать по таблице :)
                                        +1
                                        По одной таблице — такты, по другой — байты. И выбрать, что важнее.
                                          0
                                          У меня всё в одной было :)

                                      Only users with full accounts can post comments. Log in, please.