Как можно улучшить эти функции преобразования дел? - PullRequest
16 голосов
/ 07 октября 2010

В качестве учебного упражнения мои три функции - ToggleCase, LowerCase и UpperCase - каждая ожидает указатель на строку символов ASCII, оканчивающуюся нулевым символом; они работают как положено. Существуют ли более эффективные или более быстрые методы решения этой задачи? Я нарушаю какие-либо невысказанные правила хорошего C-кодирования? Я использовал макросы, потому что, я думаю, он делает код лучше и эффективнее вызовов функций. Это типично или излишне?

Пожалуйста, не стесняйтесь придираться и критиковать код (но будьте добры).

case_conversion.h

#define CASE_FLAG 32
#define a_z(c) (c >= 'a' && c <= 'z')
#define A_Z(c) (c >= 'A' && c <= 'Z')

void ToggleCase(char* c);
void LowerCase(char* c);
void UpperCase(char* c);

case_conversion.c

#include "case_conversion.h"

void ToggleCase(char* c)
{
 while (*c)
 {
  *c ^= a_z(*c) || A_Z(*c) ? CASE_FLAG : 0;
  c++;
 }
}
void LowerCase(char* c)
{
 while (*c)
 {
  *c ^= A_Z(*c) ? CASE_FLAG : 0;
  c++;
 }
}
void UpperCase(char* c)
{
 while (*c)
 {
  *c ^= a_z(*c) ? CASE_FLAG : 0;
  c++;
 }
}

Ответы [ 12 ]

15 голосов
/ 07 октября 2010

Мои любимые:

*c += (*c-'A'<26U)<<5; /* lowercase */
*c -= (*c-'a'<26U)<<5; /* uppercase */
*c ^= ((*c|32U)-'a'<26)<<5; /* toggle case */

Поскольку вашей целью будут встраиваемые системы, вы должны научиться устранять ненужный разлет кода, ветки и т. Д. Ваше условие для определения, является ли символ ascii алфавитным, равно 4 операциям сравнения / ветвления; у меня 1. Я бы порекомендовал поискать хорошие ресурсы по арифметике и трюкам с битами.

Примечание: я изменил операции *32 на <<5 после публикации своего ответа, потому что многие компиляторы встроенных систем слишком бедны, чтобы сделать это для вас. При написании кода для хорошего компилятора *32, вероятно, лучше проиллюстрирует ваше намерение.

Редактировать: Что касается обвинения в том, что в моем коде слишком много неявных операций, сгенерированных компилятором, я считаю, что это полностью неверно. Вот псевдо-asm, который должен сгенерировать любой полуприличный компилятор для первой строки:

  1. Загрузите *c и увеличьте его до нуля или знака, чтобы заполнить слово int (в зависимости от того, является ли обычный char со знаком или без знака).
  2. Вычтите константу 26, используя беззнаковую (без переполнения ловушку) sub инструкцию.
  3. Условный переход за остальную часть кода, если флаг переноса не установлен.
  4. Иначе, добавьте 32 к значению в *c.

Шаги 2 и 3 можно комбинировать на архитектурах, в которых вместо флагов используется операция сравнения-перехода. Единственный способ увидеть какие-либо существенные скрытые затраты, возникающие за кадром, - это если машина не может напрямую обращаться к символам или если она использует неприятное (знак / значение или дополнение) представление со знаком, в этом случае преобразование в неподписанное было бы нетривиальным. Насколько я знаю, ни одна современная встроенная архитектура не имеет этих проблем; в основном они изолированы от устаревших мэйнфреймов (и в меньшей степени от DSP).

Если кого-то беспокоит плохой компилятор, действительно выполняющий арифметику для <<5, вы можете попробовать:

if (*c-'A'<26U) *c+=32;

вместо моего кода. В любом случае это, вероятно, чище, но я обычно предпочитаю избегать операторов, чтобы я мог засунуть код в условие цикла или подобный функции макрос.

Редактировать 2: По запросу, версия первой строки без ветки:

*c += (64U-*c & *c-91U)>>(CHAR_BIT*sizeof(unsigned)-5);

*c += (64U-*c & *c-91U) >> CHAR_BIT*sizeof(unsigned)-1 << 5;

Чтобы это работало надежно, c должен иметь тип unsigned char *, а unsigned int должен быть строго шире, чем unsigned char.

9 голосов
/ 07 октября 2010

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

a_z('a' + 1);

Вызов не даст правильных результатов из-за приоритета оператора. Это легко исправить с помощью скобок:

#define a_z(c) ((c) >= 'a' && (c) <= 'z')

Но их также можно назвать так:

a_z(i++);

Этот вызов увеличится i в два раза! И это не легко исправить (если вообще) в макросе. Я бы рекомендовал использовать встроенные функции вместо этого (если необходимо - см. Ниже).

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

Вам нужны два массива, один для любого направления. Инициализируйте их как

char toUpper[128]; // we care only about standard ASCII
for (int i = 0; i < 128; i++)
  toUpper[i] = i;
toUpper['a'] = 'A';
...
toUpper['z'] = 'Z';

И преобразование тривиально:

char toUpperCase(char c)
{
  return toUpper[c];
}

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

5 голосов
/ 07 октября 2010

ПРИМЕЧАНИЕ: заголовок вопроса был отредактирован - исходное название было об оптимизации " Пожалуйста, критикуйте - оптимальная функция для преобразования строковых падежей в C", которая объясняет, почему мой ответ имеет дело скорее только с оптимизацией чем в целом "улучшение" функций.

Если вы действительно ищете самый быстрый способ сделать это, версия без ответвлений будет подходить в долгосрочной перспективе, потому что она может использовать SIMD. Кроме того, он позволяет избежать таблиц (которые могут быть слишком большими во встроенной системе, если память действительно ограничена).

Вот простой пример без ветвления без SIMD, и ToLower - тривиальное изменение по сравнению с этим.

char BranchFree_AsciiToUpper(char inchar) 
{ 
        // Branch-Free / No-Lookup 
        // toupper() for ASCII-only 
        const int ConvertVal = 'A' - 'a'; 
        // Bits to Shift Arithmetic to Right : 9 == (char-bits + 1) 
        const int AsrBits = 9; 

        int c=(int)inchar; 
        //if( (('a'-1)<c) && (c<('z'+1)) ) { c += 'A'-'a'; } 
        int LowerBound = ('a'-1) - c; 
        int UpperBound = c - ('z' + 1); 
        int BranchFreeMask = (LowerBound & UpperBound)>>AsrBits;
        c = c + (BranchFreeMask & ConvertVal); 
        return((char)c); 
}

Моя функция расширена для ясности и использует не жестко закодированные константы. Вы можете сделать то же самое в одной строке с жестко закодированными значениями, но мне нравится читаемый код; тем не менее, вот «сжатая» версия моего алгоритма. Это не быстрее, поскольку EXACT то же самое "сжалось" в одну строку .

c+=(((96-(int)c)&((int)c-123))>>9)&(-32);

Вы можете сделать ряд оптимизаций, чтобы сделать его еще быстрее. Вы можете жестко закодировать более оптимальные числа для ASCII, потому что в примере не предполагается, что какое-либо отображение кодирования, кроме a-z и A-Z, является смежными диапазонами. Например, в ASCII, если у вас нет переключателя ствола, вы можете изменить AsrBits на 4 (9-5), поскольку ConvertVal будет +/- 32 в зависимости от операции касания или подачи.

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

Кроме того, вы можете генерировать предикат без ответвлений, не используя int upcasting, но затем вам нужно будет выполнить еще пару операций (при upcasting это всего лишь одно вычитание на диапазон). Возможно, вам придется выполнить расширенные операции для SWAR, но в большинстве реализаций SIMD есть операция сравнения, которая генерирует для вас маску бесплатно.

Операции SWAR / SIMD также могут выиграть от меньшего числа операций чтения / записи в память, а записи, которые происходят, могут быть выровнены. Это намного быстрее на процессорах, которые имеют штрафы за попадание в хранилище (например, процессор ячейки PS3). Добавьте к этому простую предварительную выборку в развернутой версии, и вы сможете почти полностью избежать сбоев памяти.

Я знаю, что в моем примере много кода, но есть ZERO ответвлений (неявных или явных) и, как следствие, ошибочных предсказаний переходов. Если вы работаете на платформе со значительными штрафами за неправильное предсказание ветвлений (что справедливо для многих конвейерных встроенных процессоров), то даже без SIMD ваша оптимизированная сборка выпуска вышеуказанного кода должна выполняться быстрее, чем то, что кажется гораздо менее сложным, но создает неявные ветви .

Даже без SIMD / SWAR умный компилятор может развернуть и перемежить вышеприведенную реализацию, чтобы скрыть задержки и получить очень быструю версию - особенно на современных суперскалярных процессорах, которые могут выдавать более одной независимой инструкции в цикл. Обычно это невозможно с любой из версий ветвления.

Если вы развернете вручную, я бы сгруппировал загрузки и собрал хранилища, чтобы компилятору было проще чередовать не ветвящиеся независимые инструкции между ними. Пример:

// Unrolled inner loop where 'char *c' is the string we're converting
char c0=c[0],c1=c[1],c2=c[2],c3=c[3];  // Grouped-Loads
c[0]=BranchFree_AsciiToUpper(c0);
c[1]=BranchFree_AsciiToUpper(c1);
c[2]=BranchFree_AsciiToUpper(c2);
c[3]=BranchFree_AsciiToUpper(c3);
c+=4;

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

mr               r31,r3
mr               r13,r13
lbz              r11,0(r31)
lbz              r10,1(r31)
extsb            r11,r11
lbz              r9,2(r31)
extsb            r10,r10
lbz              r8,3(r31)
subfic           r7,r11,96
addi             r6,r11,-123
srawi            r5,r7,9
srawi            r4,r6,9
subfic           r3,r10,96
addi             r7,r10,-123
extsb            r9,r9
srawi            r6,r3,9
srawi            r3,r7,9
subfic           r7,r9,96
addi             r30,r9,-123
extsb            r8,r8
srawi            r7,r7,9
srawi            r30,r30,9
subfic           r29,r8,96
addi             r28,r8,-123
srawi            r29,r29,9
srawi            r28,r28,9
and              r5,r5,r4
and              r3,r6,r3
and              r7,r7,r30
and              r30,r29,r28
clrrwi           r4,r5,5
clrrwi           r6,r7,5
clrrwi           r5,r3,5
clrrwi           r7,r30,5
add              r4,r4,r11
add              r3,r5,r10
add              r11,r6,r9
stb              r4,0(r31)
add              r10,r7,r8
stb              r3,1(r31)
stb              r11,2(r31)
stb              r10,3(r31)

.доказательство в пудинге и вышеупомянутый скомпилированный код будет очень быстрым по сравнению с ветвящимися версиями даже до перехода к SWAR или SIMD.

Вкратце, причины, по которым этот метод должен быть самым быстрым:

  1. Нет штрафов за неверное предсказание ветвления
  2. Возможность использовать SIMD-ify алгоритм для 4-16 (или более) байтов за раз
  3. Компилятор (или программист) может развернутьи чередование для устранения задержек и использования суперскалярных (многозадачных) процессоров
  4. Без задержек в памяти (т. е. при просмотре таблиц)
2 голосов
/ 09 октября 2010

Я не решался ответить на этот вопрос, потому что прошло более 20 лет с тех пор, как я работал с небольшими устройствами.Тем не менее, я думаю, что правила в значительной степени одинаковы (с одним возможным дополнением):

  1. Минимизировать доступ к памяти
  2. Минимизировать циклы ЦП
  3. Минимизировать размер кода

Когда я разрабатывал низкоуровневый код, правило № 1 затмило все остальные.Там не было никакого встроенного кеша, и память была невероятно медленной по сравнению с процессором;По этой причине в C. существует класс хранения «register». Сегодня ситуация несколько изменилась, но это все еще одна из двух главных проблем.Как я прокомментировал в одном посте, справочная таблица является хорошей идеей, но следует признать, что она означает дополнительный доступ к памяти для каждого теста.Как только он попадет в кеш, это может не быть проблемой, но вы будете платить цену за несколько попаданий в кеш при каждом входе в функцию (если вы не вызываете ее так часто, что таблица поиска может остаться в кеше).

Правило № 2 выглядит так: «Да, конечно, вы хотите это сделать, почему это не правило № 1?»но рассуждения на самом деле идут глубже.Фактически, в некотором смысле это повторение правила № 1, поскольку каждая инструкция должна быть извлечена из памяти, прежде чем она может быть выполнена.Здесь есть тонкий компромисс: на целочисленном процессоре можно без труда использовать справочную таблицу для вычисления тригонометрических функций;на чипе со встроенной плавающей запятой, может и нет.

Я не уверен, что правило № 3 все еще применяется.По моему опыту, всегда была схватка, чтобы сократить код, поместив пресловутые 20 фунтов в мешок 10 фунтов.Но, похоже, сегодня самый маленький мешок составляет 50 фунтов.Тем не менее, даже с мешком в 50 фунтов (или много-мегабайтным ПЗУ) для хранения вашего кода / данных, вам все равно нужно поместить его в кэш (если он у вас есть).

Новое правило# 1: поддерживать конвейер заполненным

Современные процессоры имеют конвейеры глубоких инструкций (если вы не знакомы с этим термином, см. Эту статью: http://arstechnica.com/old/content/2004/09/pipelining-1.ars/1). Общее правило с глубокимконвейеры в том, что ветвление - тест "если" - дорого, потому что это означает, что конвейер, возможно, придется очистить для загрузки в новом коде. Таким образом, вы пишете свой код для ветвления в маловероятном случае (см. * 1023).Сообщение * Adisak о возможной оправданной реализации без веток; +1, если бы я мог).

Кто-то с более недавним опытом, чем я, вероятно, прокомментирует и скажет: "Современные процессоры загружают конвейерс обоими ответвлениями, поэтому нет штрафов за издержки. "Все это хорошо, но это поднимает общее правило:

Правило 0: оптимизация зависит от вашей архитектуры и рабочей нагрузки

Микропроцессор внутри моей посудомоечной машины, вероятно, не имеет конвейера и, возможно, не имеет кеша.Конечно, это, вероятно, не собирается делать большую обработку текста также.Или, может быть, и то и другое;кажется, что на рынке есть только несколько основных встроенных процессоров, так что, возможно, на этой плате есть Pentium, а не производная 8051.Несмотря на это, существует широкий диапазон даже среди встроенных процессоров на базе Pentium (http://en.wikipedia.org/wiki/List_of_Intel_Pentium_microprocessors#Embedded_processors). Что лучше для одного, может быть не лучше для другого.

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

Однако есть еще кое-что: я прокомментировал "Только ASCII, а?"на ОП;другой комментатор был более явным: если вы обрабатываете текст в 2010 году, вы, вероятно, не обрабатываете ASCII.По крайней мере, вы будете иметь дело с ISO-8859-1 или аналогичным 8-битным набором символов.И в этом случае, возможно, решение без ответвления или с умным ответвлением (обращая внимание на конвейер) будет все же быстрее, чем справочная таблица (да, это предположение с моей стороны).Но если вы имеете дело с Unicode BMP (16 бит), вам в значительной степени придется использовать таблицу независимо от ее стоимости с точки зрения памяти, потому что нет простых правил для определения того, что находится в нижнем и верхнем регистре.И если вы имеете дело с высшими планами Юникода ... ну, возможно, заглавные буквы "Старый курсив" не так важны (особенно потому, что в нем нет прописных и строчных букв).

В конечном счете, единственный способ узнать наверняка - это профилировать данные реалистичные рабочие нагрузки.

Наконец: Очистить код FTW

Этот пост начался, когда я написал комментарий кOP, что его / ее использование макросов было плохой идеей (и не могло войти в него, потому что SO перешел в режим обслуживания).Питер Торок (извините, я не поддерживаю Unicode или даже ISO-8859-1) привел одну причину, но есть и другая: это черные ящики.

ОП выглядит красиво и чисто: короткий код, тяжелыйиспользование побитовых и троичных операторов, легко понять, если вы понимаете язык.Но было бы намного проще понять реальную работу, если бы вы увидели A_Z в развернутом виде.Это могло бы заставить вас задуматься о том, сколько разветвлений вы делали, особенно в методе ToggleCase.И затем вы могли бы подумать о том, как вы могли бы реорганизовать эти ветви, чтобы минимизировать количество реальных тестов, которые вы делаете.И, возможно, подумать о поддержании трубопровода.

2 голосов
/ 07 октября 2010

Хорошо, здесь идет.Пишем на этой вкладке ... прокручиваем ваш код на другой вкладке: -)

header

  1. #define a_z(c) (c >= 'a' && c <= 'z')

    • theимя функции наподобие макроса должно быть во ВСЕХ КЕПСАХ (возможно IS_LOWERCASE), чтобы предупредить пользователей, что это макрос
    • c в расширении должен быть внутри скобок, чтобы предотвратить странные побочные эффекты
    • личный выбор : Я хотел бы изменить порядок условий, чтобы читать больше как английский 'a' <= c <= 'z' как <code>(('a' <= (c)) && ((c) <= 'z'))
  2. Я бы заставил функции void ToggleCase(char* c) вернуть char* (то же, что было отправлено), чтобы иметь возможность использовать их в последовательности: printf("%s\n", UpperCase(LowerCase("FooBar")));

исходный код

  1. Тернарный оператор не делает ваш код быстрее или проще для чтения.Я бы написал простой if

Вот и все.

О!Еще одна вещь: ваш код предполагает ASCII (вы сами так сказали), но не документируете это.Я бы добавил примечание об этом в заголовочный файл.

1 голос
/ 04 ноября 2010

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

ANSI C включает в стандартную библиотеку необходимые функции, и, вероятно, они были сильно оптимизированы для вашей архитектуры поставщиком компилятора.

Стандартный заголовок ctype.h включает функции tolower () и toupper ().

0 голосов
/ 09 октября 2010

Если кто-то пытается обработать несколько байтов одновременно, я думаю, что наилучшим подходом было бы заставить все значения быть равными 0.127, добавить 5 или 37 (что сделало бы «z» - «Z» 127) , обратите внимание на это значение, а затем добавьте 26, запомните это значение, а затем выполните некоторые манипуляции. Что-то вроде:

unsigned long long orig,t1,t2,result;

t1 = (orig & 0x7F7F7F7F7F7F7F7F) + 0x0505050505050505;
t2 = t1 + 0x1A1A1A1A1A1A1A1A;
result = orig ^ ((~(orig | t1) & t2 & 0x8080808080808080) >> 2);

Хм ... Полагаю, это хорошо работает, даже если адаптировано для 32-битной машины. Если четыре регистра предварительно загружены с надлежащими константами, ARM мог бы с оптимальным кодом, вероятно, выполнить операции с семью инструкциями, занимающими семь циклов; Я сомневаюсь, что компилятор найдет оптимизации (или выяснит, что было бы полезно сохранить константы в регистрах - если константы не хранятся в регистрах, обработка байтов в отдельности будет быстрее).

0 голосов
/ 08 октября 2010

Я использовал макросы, потому что, я думаю, он делает код лучше и эффективнее вызовов функций.

Является ли он более эффективным?Каковы ваши требования к размеру кода?(Для сгенерированного исполняемого кода, а не исходного кода на языке C.) В современных настольных системах это редко является проблемой, а скорость имеет гораздо большее значение;но вы не дали нам больше подробностей, кроме «приложений для встраиваемых систем», поэтому мы не сможем ответить вам за это.Однако здесь это не проблема, потому что код внутри макросов действительно настолько мал, но вы не можете предполагать, что избегание вызовов функций всегда более эффективно!

Вы можете использовать встроенные функции, если выпозволил.Они официально являются частью C с 99 года, но поддерживаются гораздо дольше в нескольких компиляторах.Встроенные функции намного чище, чем макросы, но, опять же, в зависимости от ваших точных целевых требований, может быть сложно предсказать сгенерированный код из исходного кода.Чаще, однако, люди застряли с устаревшими (сейчас более десяти лет!) Компиляторами C, которые их не поддерживают.

Короче говоря, вы всегда должны знать свои точные требования, чтобы определить, что оптимально.А затем вам нужно проверить, чтобы проверить ваши прогнозы производительности .

0 голосов
/ 08 октября 2010

Мой подход «обрезать только при необходимости».

В зависимости от вашей системы и архитектуры вашего процессора, многие вещи могут быть выполнены по-разному.будет иметь отношение к вашему коду.Во-первых, макросы.Макросы имеют некоторые жестокие ловушки и должны использоваться с осторожностью.Во-вторых, использование глобального переключателя.Я переписал бы, чтобы выглядеть примерно так -

 enum CASE {UPPER, LOWER};

void ToggleCase(char* c, CASE newcase)
{
    if(newcase == UPPER)
       UpperCase(c);
    else if(newcase == LOWER)
       LowerCase(c);
    else 
       { ; } //null
}

В смысле микроэффективности, это добавляет около 1 дополнительной инструкции на вызов.Также может произойти некоторое ветвление, которое может вызвать пропадание кэша.

void LowerCase(char* c)
{
  while (*c++)  //standard idiom for moving through a string.
  {
    *c = *c < 'Z' ? *c + 32 : *c;
  }
}


void UpperCase(char* c)
{
  while (*c++)
  {
    *c = *c > 'a' ? *c - 32 : *c;
  }
}

Теперь есть некоторые критические замечания по моему коду.

Во-первых, это ветвисто.Во-вторых, предполагается, что вводом является [a-zA-Z] +.В-третьих, это только ASCII (как насчет EBDIC?).В-четвертых, предполагается нулевое завершение (в некоторых строках есть символы в начале строки - я думаю, Паскаль).В-пятых, это не на 100% наивно очевидно, что код прописные / строчные.Также обратите внимание, что ENUM является плохо завуалированным целым числом.Вы можете передать ToggleCase("some string", 1024) и он скомпилируется.

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

0 голосов
/ 07 октября 2010

Возможно, я провел слишком много времени с C ++ и недостаточно с C, но я не большой поклонник макросов с параметрами ... как указывает Питер Торок, они могут привести к некоторым проблемам.Ваше определение CASE_FLAG в порядке (оно не принимает никаких параметров), но я бы вместо макросов a_z и A_Z заменил их функциями.

...