Кто быстрее: memset, bzero или std::fill

У нас тут идет второй день тренинг по С++ и unit-тестированию. Ведет Kevlin Henney. Отличный дядка.

Все как обычно – стараемся приучать к культуре разработки через тесты, ну и попутно склонить патриотов С к С++, убедив их, что на С++ можно таки писать также эффективно, как и на С. Да еще и в разы быстрее.

Зашла тема про std::fill(). Я вставил словечко, что мол fill() работает также быстро как и memset(), так как он используется в fill() для простых типов.

Написали программу, в которой есть интересный момент.

#include <cstdlib>
#include <algorithm>

int main(int argc, char* argv[]) {
  int mode = argc > 1 ? std::atoi(argv[1]) : 1;
  int n = 1024 * 1024 * 1024 * 1;
  char* buf = new char[n];
  if (mode == 1)
    std::memset(buf, 0, n * sizeof(*buf));
  else if (mode == 2)
    bzero(buf, n * sizeof(*buf));
  else if (mode == 3)
    std::fill(buf, buf + n, 0);
  else if (mode == 4)
    std::fill(buf, buf + n, '\0');
  return buf[0];
}

Обратите внимание на ветки 3 и 4. Они почти одно и то же, но не совсем.

В целом была мысль получить вот эту специализацию fill():

// Specialization: for one-byte types we can use memset.
inline void
fill(unsigned char* __first, unsigned char* __last, const unsigned char& __c)
{
  __glibcxx_requires_valid_range(__first, __last);
  const unsigned char __tmp = __c;
  std::memset(__first, __tmp, __last - __first);
}

Итак, Makefile:

all: build run

.SILENT:

target = memset_bzero_fill

build:
        g++ -O3 -o $(target) $(target).cpp

run: run-memset run-bzero run-fill-1 run-fill-2

go:
        (time -p ./$(target) $(mode)) 2>&1 | head -1 | cut -d' ' -f 2

run-memset:
        echo $@ `$(MAKE) go mode=1`

run-bzero:
        echo $@ `$(MAKE) go mode=2`

run-fill-1:
        echo $@ `$(MAKE) go mode=3`

run-fill-2:
        echo $@ `$(MAKE) go mode=4`

Компилятор “gcc version 4.2.1 (Apple Inc. build 5666) (dot 3)”.

Запускаем:

run-memset 1.47
run-bzero 1.45
run-fill-1 1.69
run-fill-2 1.42

Видно, как ветка 3 (run-fill-1) значительно тормозит, по сравнению с 4, хотя разница всего в типе последнего параметра - 0 и ‘\0’.

Смотрим ассемблер:

(gdb) disass main
Dump of assembler code for function main:
0x0000000100000e70 <main+0>:    push   %rbp
0x0000000100000e71 <main+1>:    mov    %rsp,%rbp
0x0000000100000e74 <main+4>:    push   %r12
0x0000000100000e76 <main+6>:    push   %rbx
0x0000000100000e77 <main+7>:    dec    %edi
0x0000000100000e79 <main+9>:    jle    0x100000ec3 <main+83>
0x0000000100000e7b <main+11>:   mov    0x8(%rsi),%rdi
0x0000000100000e7f <main+15>:   callq  0x100000efe <dyld_stub_atoi>
0x0000000100000e84 <main+20>:   mov    %eax,%r12d
0x0000000100000e87 <main+23>:   mov    $0x40000000,%edi
0x0000000100000e8c <main+28>:   callq  0x100000ef8 <dyld_stub__Znam>
0x0000000100000e91 <main+33>:   mov    %rax,%rbx
0x0000000100000e94 <main+36>:   cmp    $0x1,%r12d
0x0000000100000e98 <main+40>:   je     0x100000eac <main+60>   ; mode == 1
0x0000000100000e9a <main+42>:   cmp    $0x2,%r12d
0x0000000100000e9e <main+46>:   je     0x100000eac <main+60>   ; mode == 2
0x0000000100000ea0 <main+48>:   cmp    $0x3,%r12d
0x0000000100000ea4 <main+52>:   je     0x100000ed2 <main+98>   ; mode == 3
0x0000000100000ea6 <main+54>:   cmp    $0x4,%r12d
0x0000000100000eaa <main+58>:   jne    0x100000ebb <main+75>   ; mode != 4 -> выход

; Реалиазация через memset().

0x0000000100000eac <main+60>:   mov    $0x40000000,%edx        ; mode = 1, 2 или 4
0x0000000100000eb1 <main+65>:   xor    %esi,%esi
0x0000000100000eb3 <main+67>:   mov    %rbx,%rdi
0x0000000100000eb6 <main+70>:   callq  0x100000f0a <dyld_stub_memset>

0x0000000100000ebb <main+75>:   movsbl (%rbx),%eax             ; выход
0x0000000100000ebe <main+78>:   pop    %rbx
0x0000000100000ebf <main+79>:   pop    %r12
0x0000000100000ec1 <main+81>:   leaveq
0x0000000100000ec2 <main+82>:   retq

0x0000000100000ec3 <main+83>:   mov    $0x40000000,%edi
0x0000000100000ec8 <main+88>:   callq  0x100000ef8 <dyld_stub__Znam>
0x0000000100000ecd <main+93>:   mov    %rax,%rbx
0x0000000100000ed0 <main+96>:   jmp    0x100000eac <main+60>

; Реализация на обычных командах.

0x0000000100000ed2 <main+98>:   movb   $0x0,(%rax)             ; mode = 3
0x0000000100000ed5 <main+101>:  mov    $0x1,%eax
0x0000000100000eda <main+106>:  nopw   0x0(%rax,%rax,1)
0x0000000100000ee0 <main+112>:  movb   $0x0,(%rax,%rbx,1)
0x0000000100000ee4 <main+116>:  inc    %rax
0x0000000100000ee7 <main+119>:  cmp    $0x40000000,%rax
0x0000000100000eed <main+125>:  jne    0x100000ee0 <main+112>

0x0000000100000eef <main+127>:  movsbl (%rbx),%eax             ; выход
0x0000000100000ef2 <main+130>:  pop    %rbx
0x0000000100000ef3 <main+131>:  pop    %r12
0x0000000100000ef5 <main+133>:  leaveq
0x0000000100000ef6 <main+134>:  retq

Видно, что благодаря оптимизации, ветки 1, 2 и 4 реализованы одинаково - через memset(). Вызов fill() в ветке 4 удалось свести к memset().

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

Неверный тип нуля не дал компилятору правильно выбрать специализацию шаблона.

Мораль? И мораль тут не очень хорошая.

Мне кажется, что количество людей, которые напишут std::fill(buf, buf + n, 0), разительно больше, чем std::fill(buf, buf + n, '\0').

А разница весьма существенна.

Посты по теме:


Оригинальный пост | Disclaimer

Комментарии