LINUX.ORG.RU

Кодим на ассемблере в рождество

 


1

2

С Рождеством ЛОРчик!

Что мы делаем в Рождество? Кодим на ассемблере конечно же!

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

Я сначала удивился простоте задачи, минут за 5 набросал решение на С++ с использованием remove_if(). Потом подумал, наверное это не совсем честно, ведь нужно самостоятельное решение, а remove_if() - это вариант для ленивых, уж очень напоминает решение с библиотечной функцией. И я переделал код на свой собственный цикл в С++ стиле, на итераторах.

Замерил быстродействие. Команда time выдавала стабильные 0,005 - 0,006 секунды.

Вроде бы и все, задача решена. Я немного отдохнул, сходил попил чаю. И тут меня осенило. А смогу ли я написать то же самое на чистом Си так, чтобы оно работало быстрее?

И я попробовал. Переписал решение на Си, с адресной арифметикой во все поля. Очень старался не делать ничего лишнего, только самое необходимое и все в одном цикле. Замерил быстродействие. Оно оказалось те же 0,005 секунды. Но 0,006 уже не появлялось никогда. Т.е. может быть мы немножко выиграли, какую-нибудь половину тысячной доли секунды.

Но! Я бы не стал писать пост ради этого. Как вы понимаете, потом меня понесло! :-)

Я попил еще чаю. Поел маминого супа. И решил написать все то же самое, только на ассемблере! Мне было интересно, смогу ли я переплюнуть результаты Си-шного кода.

Вот тут пришлось серьезно постараться. Кажется, это будет мое первое приложение на ассемблере под мак.

Сколько раз мое приложение падало, наверное не сосчитать. Я убил на него полдня! Сломал себе весь мозг. Едва не навернул всю систему, особенно пока искал нужный системный вызов. Но я, таки, справился.

Короче, идея, в общем-то, проста. У нас есть некая строка в секции данных приложения. Там же мы себе оставили буфер для будущей новой строки. Ну и переписываем в этот буфер по байтам старую строчку. Если нам встречается пробел, мы его не переписываем. Для этого в ассемблере есть специальные команды, lods и stos, которые сами увеличат и уменьшат нужные заначения в регистрах. Нам для них нужно только подготовить начальные данные. Командой cmp мы сравниваем байты. Командой je (jump if equivalent) прыгаем на нужную инструкцию по результату сравнения. Регистр r10 я использовал, чтобы сохранить длину нашей новой строки. Почему r10? Не знаю, вроде он был следующий по конвенции вызовов, остальные, предыдущие мы уже использовали.

Чтобы напечать строки, дергаем системный вызов write(). Здесь я тоже надеялся немного выиграть, за счет того, что не использую библиотечные функции, а напрямую прося операционную систему печтать в stdout. Так как операционка - macos, системные вызовы у нее оказались под другими номерами, не как в Linux. Пришлось изрядно постараться, чтобы найти наконец нужный. Хорошо хоть параметры передавались в тех же регистрах, что и в Linux. Видимо соблюдалась конвенция о системных вызовах.

Другой новостью оказалсь RIP-related адресация. Может быть я забыл, но это было несколько неожиданно. Когда последний рза писал на ассемблере для Linux, вроде не сталкивался с этим. В общем, теперь мы не можем просто передавать адрес объекта внутри бинарника куда-нибудь еще. Этот адрес нужно вычислять относительно RIP - register instruction pointer. По-идее, это хорошее нововведение, потому что объекты встроенные в бинарник более не зависят от адреса загрузки этого бинарника поскольку вычисляются из адреса текущей исполняемой инструкции.

Я был очень рад, когда моя штуковина заработала. С трепетом замеряю быстродействие….

Нет, те же 0,005 тысячных долей секунды. Я не смог переплюнуть в написании кода Си-шный компилятор. Иногда, в некоторых, очень редких замерах проскакивает отметка 0,004 секунды. С натяжкой можно предположить, что мы обошли на 0,001 тысяную доли компиляторный код. Но это настолько мало, что можно списать на погрешность измерений.

ЛОР, скажи, а был ли у меня шанс обогнать компилятор? Можно ли как-то улучшить код?

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

.bss
str_out:
    .space 256

.global _main

.text

# rsi: msg, rdx: len
_print:
    movq $0x2000004, %rax            # system call write
    movq $1, %rdi                    # id handler 1 is stdout
    syscall
    ret

# rsi: from, rdi: to, rcx: count
# r10: current index
_copy:
    lodsb 
    cmpb $32, %al
    je _cpe
    stosb
    inc %r10
_cpe:
    loop _copy
    ret

_main:

    movq msg@GOTPCREL(%rip), %rsi    # address of string to output
    movq $msg_len, %rdx              # number of bytes
    call _print

    movq msg@GOTPCREL(%rip), %rsi
    movq str_out@GOTPCREL(%rip), %rdi
    movq $msg_len, %rcx
    xor %r10, %r10
    call _copy
    
    movq str_out@GOTPCREL(%rip), %rsi
    movq %r10, %rdx                  # number of bytes
    call _print

    ret

.cstring
msg:
    .ascii "String spaces remover\n"
    msg_len = . - msg


Перемещено Zhbert из linux-org-ru

★★★★★

Ответ на: комментарий от anonymous

почти все приедённые в топика варианты будут плюс-минус одинаковые.

Для clang - да, для gcc - нет. Сlang генерит почти одинаковый код, но более медленный по сравнению с gcc. А вот gcc при некоторых вариантах чудит (в положительном смысле).

anonymous
()
Ответ на: комментарий от anonymous

где avx-инструкции?

Не сильно помогут, так как нет четких границ конца строки, надо последовательно искать ‘\0’, чтобы не вылезти за конец строки.

anonymous
()
Ответ на: комментарий от anonymous

а мне кажется, что даже с поиском нуля должно получиться сильно быстрее. там двумя-тремя инструкциями можно сразу по 16 или даже может 32 байта обрабатывать. это, конечно, не точно, но, судя по Memory location unchanged, инструкция — что надо.

anonymous
()
/* коперигхт Ц ЛИНУКС-ORG-РУ, всюе прова резервед */
#include <stdio.h>
int main(int argc, char *argv[])
{
    char out[256]={0}; 
    char*ptr=out;
    while(*argv[1]++ && *ptr ){ switch(*argv[1])
    {
        case ' ' : break;
        default  : *ptr=*argv[1];ptr++;break;
    }}
    puts(out);
    return 0;
}

gcc fast.c -03 -msse -S

	.file	"fast.c"
	.text
	.section	.text.startup,"ax",@progbits
	.p2align 4
	.globl	main
	.type	main, @function
main:
.LFB11:
	.cfi_startproc
	subq	$264, %rsp
	.cfi_def_cfa_offset 272
	pxor	%xmm0, %xmm0
	movq	8(%rsi), %rcx
	movq	%rsp, %r8
	movups	%xmm0, (%rsp)
	movups	%xmm0, 16(%rsp)
	movq	%r8, %rdi
	movups	%xmm0, 32(%rsp)
	movups	%xmm0, 48(%rsp)
	movups	%xmm0, 64(%rsp)
	movups	%xmm0, 80(%rsp)
	movups	%xmm0, 96(%rsp)
	movups	%xmm0, 112(%rsp)
	movups	%xmm0, 128(%rsp)
	movups	%xmm0, 144(%rsp)
	movups	%xmm0, 160(%rsp)
	movups	%xmm0, 176(%rsp)
	movups	%xmm0, 192(%rsp)
	movups	%xmm0, 208(%rsp)
	movups	%xmm0, 224(%rsp)
	movups	%xmm0, 240(%rsp)
.L2:
	leaq	1(%rcx), %rax
	.p2align 4,,10
	.p2align 3
.L3:
	movq	%rax, 8(%rsi)
	cmpb	$0, -1(%rax)
	movq	%rax, %rcx
	jne	.L4
	cmpb	$0, (%rdi)
	je	.L9
.L4:
	movzbl	(%rax), %edx
	addq	$1, %rax
	cmpb	$32, %dl
	je	.L3
	movb	%dl, (%rdi)
	addq	$1, %rdi
	jmp	.L2
	.p2align 4,,10
	.p2align 3
.L9:
	movq	%r8, %rdi
	call	puts@PLT
	xorl	%eax, %eax
	addq	$264, %rsp
	.cfi_def_cfa_offset 8
	ret
	.cfi_endproc
.LFE11:
	.size	main, .-main
	.ident	"GCC: (Debian 10.2.1-3) 10.2.1 20201224"
	.section	.note.GNU-stack,"",@progbits
LINUX-ORG-RU ★★★★★
()
Последнее исправление: LINUX-ORG-RU (всего исправлений: 2)
Ответ на: комментарий от anonymous

Скорость измеряется не количеством инструкций.

а я и не измерял. но кто поспорит, что в среднем между количеством последовательно выполняемых инструкций и скоростью выполнения есть почти линейная зависимость? на x86, конечно.

anonymous
()

0,005 тысячных долей секунды

У меня даже на intel T7700 так медленно строки не работают. Или это вместе с запуском и завершением программы?

SR_team ★★★★★
()
Ответ на: комментарий от anonymous

-O2 vs -O3

Замедляет добавление оптимизации -fsplit-paths, которая включается при -O3.

Для этого кода:

void remove_spaces0(const char* str, char* out) {
    while (const char c = *str++) {
        if (c != ' ')
            *out++ = c;
    }
    *out = '\0';
}
anonymous
()
Ответ на: комментарий от anonymous

где avx-инструкции?

Не сильно помогут

Анонич, у меня получилось!

$ yes "This is a test message 1 2 3 4 " | dd of=text.txt bs=1024 count=2M
2147483648 bytes (2.1 GB, 2.0 GiB) copied

$ python gen_map.py > map.bin
$ gcc -O2 remove_spaces.S remove_spaces.c -no-pie -o remove_spaces

$ time ./remove_spaces text.txt
File size: 2147483648
Spaces found (straight): 603979776
"Thisisatestmessage1234"
./remove_spaces text.txt  2.12s user 0.20s system 99% cpu 2.327 total

$ time ./remove_spaces text.txt asm
File size: 2147483648
Spaces found (asm): 603979776
"Thisisatestmessage1234"
./remove_spaces text.txt asm  0.26s user 0.19s system 97% cpu 0.465 total

Почти ровно в 5 раз быстрее :)

Ссылки на исходники: gen_map.py, remove_spaces.S, remove_spaces.c.

anonymous
()
Ответ на: комментарий от anonymous

Ты в принципе не понял основную задачу и трудность: заранее неизвестна длина строки - конец строки определяется по ‘\0’ - это сишная строка.

Теперь то же самое, но без count

char* remove_spaces_asm16(char *in, char *out/*, unsigned int count - не нужен*/);
anonymous
()
Ответ на: комментарий от anonymous
vpxor xmm4, xmm4, xmm4
...

1:
    ...
    vpcmpeqb    xmm1, xmm4, xmm0
    vpmovmskb   rax, xmm1
    cmp         rax, 0
    je 1b

Никакой трудности в этом нет. Разве что для тебя. Основная же задача — удалить пробелы — выполняется в любом случае.

А ты тогда теперь давай xотя бы x2 ускорение на сишке без simd, но с count, раз уж в нём увидел решение.

anonymous
()
Ответ на: комментарий от anonymous

Никакой трудности в этом нет.

Вперед, показывай. Но смотри, чтобы «случайно» не прочитал лишнее за пределами строки vmovdqu xmm0, [rdi].

anonymous
()
Ответ на: комментарий от anonymous

Не хочу уже. И почему это мне нельзя прочитать за пределами строки? Хочу — читаю, не хочу — не читаю. Ты мне что ли запрещать будешь?

anonymous
()
Ответ на: комментарий от anonymous

И почему это мне нельзя прочитать за пределами строки?

Ты не только читаешь (невыровненные данные), но и записываешь.

Ты мне что ли запрещать будешь?

Мне то всё равно, но ОС (может) не понравится.

anonymous
()
Ответ на: комментарий от anonymous

Я не переживаю. Я видел код glibc memcpy и memmove для sse и avx. А твой remove_spaces_asm - это более сложный memcpy, но почему-то он у тебя несравнимо «проще». Так что не о чем переживать.

anonymous
()
Ответ на: комментарий от anonymous

Ну я ж не буду тебе сейчас как для прода тут ваять. Концепт работает, да и бог с ним, остальное — дело техники. Тем более, что ты уже всё видел как оно делается ;)

anonymous
()
Ответ на: комментарий от anonymous

Я не видел, так как не запускал твой сомнительный код. Потому что неизвестно, что он измеряет: зачем данные в файле, зачем mmap, зачем измерять время запуска процесса, почему решается другая задача, почему пытаешься убедить, что надо решать твою задачу, а не исходную?

Сделай правильный тест скорости для правильной реализации задачи.

anonymous
()
Ответ на: комментарий от anonymous

Красава, затащил. Вот только времени много уходит? Было бы круто если бы все на асме кодили, вот ток он долгий очень в плане програмирования.

anonymous
()
Ответ на: комментарий от anonymous

Пф, да никого я ни в чём не пытаюсь убедить. Исходная задача — удалить пробелы из строки — решается на отлично. Что ещё надо? Придираешься чего-то тут. Код какой-то сомнительный ему. Данные — ассоциации между расположением пробелов в блоке и вариантами перетасовывания байтов, плюс табличка с посчитанным количеством непробелов для каждого варианта. Для 16-байтных блоков «данные» занимают 2^16 * (16 + 1) байт. mmap — чтобы замапить текстовый файл с пробелами на память, очевидно. Бинарь один, поэтому запускается в обоих случаях за одно и то же время. В конце концов, мы же не абсолютное время считаем.

Какой там тебе надо тест, ты и сам сделаешь, если захочешь.

anonymous
()
Ответ на: комментарий от anonymous

Ну да, конечно времени уходит в разы больше, чем если писать на питончике, например. Я на Википедии где-то табличку видел, где сравнивались выразительности разных языков, асма там не было, но вот, например, если писать на питоне а не на си, то в среднем по той табличке получалось сокращение кода что-то около в 5—6 раз.

Если бы все кодили на асме, то тогда под каждую платформу пришлось писать бы свой вариант программы.

anonymous
()
Ответ на: комментарий от anonymous

Какой там тебе надо тест, ты и сам сделаешь, если захочешь.

Я то могу, но не хочу (сволочь я).

Замени на end = remove_spaces_asm16(in + fs.st_size - 1, out, 1);. Что будет если ты прочитаешь за границей замапленой памяти?

anonymous
()

0.005 с и 0.006 с - серьёзно?

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

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

Короче, по метрологии и performance-тестированию незачёт.

sadko4u ★★
()

А напиши алгоритм замены inplace за линейное время

OxiD ★★★★
()
Вы не можете добавлять комментарии в эту тему. Тема перемещена в архив.