Go обгоняет C на задаче с объёмным выводом

Задача состоит в выводе последовательностей вида: 000 001 002 ... 997 998 999.

Go

package main

import (
    "bufio"
    "fmt"
    "os"
)

func main() {
    var n int
    fmt.Scan(&n)

    buffer := bufio.NewWriterSize(os.Stdout, 64 * 1024)

    b := make([]byte, n+1)
    b[n] = '\n'

    var search func(i int)
    search = func(i int) {
        if i == n {
            buffer.Write(b)
        } else {
            for d := '0'; d <= '9'; d++ {
                b[i] = byte(d)
                search(i + 1)
            }
        }
    }
    search(0);
    buffer.Flush()
}

C

#include <stdio.h>
#include <stdlib.h>

#define BSIZE (64 * 1024)

void search(char b[], int n, int i) {
    if (i == n) {
        fwrite(b, 1, n + 1, stdout);
    } else {
        for (char d = '0'; d <= '9'; ++d) {
            b[i] = d;
            search(b, n, i + 1);
        }
    }
}

int main() {
    char *buf = malloc(BSIZE);
    if (buf == NULL) {
        return 1;
    }

    if (setvbuf(stdout, buf, _IOFBF, BSIZE) != 0) {
        return 1;
    }

    int n;
    if (scanf("%d", &n) != 1) {
        return 1;
    }

    char *b = malloc((size_t)n + 1);
    if (b == NULL) {
        return 1;
    }
    b[n] = '\n';
    search(b, n, 0);
}

Сборка и проверка:

$ gcc -O3 temp.c 

$ time diff <(echo 8 | go run temp.go) <(echo 8 | ./a.out)

real  0m4.043s
user  0m3.816s
sys   0m1.100s

Сравнение времён. На ста миллионах C отстаёт на полсекунды:

$ time echo 8 | go run temp.go | wc -l
100000000

real  0m1.641s
user  0m2.396s
sys   0m0.216s

$ time echo 8 | ./a.out | wc -l
100000000

real  0m2.120s
user  0m2.932s
sys   0m0.172s

На миллиарде C отстаёт на почти шесть секунд:

$ time echo 9 | go run temp.go | wc -l
1000000000

real  0m16.166s
user  0m24.524s
sys   0m2.352s

$ time echo 9 | ./a.out | wc -l
1000000000

real  0m22.044s
user  0m30.720s
sys   0m2.424s

Я иногда использую код на С в качестве эталона в задачах, чтобы понять сколько времени уходит на вычисления, сколько на вывод. Но тут эталон сломался. Что я делаю не так?

P.S. Вычисления не тормозят: если в обоих вариантах удвоить вывод, разница во времени почти удвоится. Это именно вывод.


Ответы (4 шт):

Автор решения: Pak Uula

Это не ответ, а развёрнутый комментарий.

Я сделал пример бенчмарка, который выводит в файл 10 тысяч раз первый абзац Lorem Ipsum

Также там есть ваш пример

Ваш пример действительно демонстрирует большую разницу в скорости выполнения: 5 секунд Си против 1.6 секунд Го.

НО! бенчмарк с чистым выводом такое не показывает.

И Си, Го в бенчмарке действуют одинаково: открывают файл, пишут в него строки, закрывают файл.

Пример для Си:

package c_out

/*
#include <stdlib.h>
#include <stdio.h>

FILE* file = NULL;
int PrepareOut() {
    file = fopen("c_out.txt", "w+b");
    return (file != NULL);
}

void CleanUp() {
    fclose(file);
}

const char LoremIpsum[] = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n";

void Run(int n) {
    for (int i = 0; i < n; i++) {
        fputs(LoremIpsum, file);
    }
    fflush(file);
}

*/
import "C"

func Prepare() bool {
    return C.PrepareOut() != 0
}

func Run(n int) {
    C.Run(C.int(n))
}

func CleanUp() {
    C.CleanUp()
}

Пример для Go

package go_out

import (
    "bufio"
    "os"
)

var file *os.File

func Prepare() bool {
    f, err := os.Create("go_out.txt")
    if err != nil {
        return false
    }
    file = f
    return true
}

func CleanUp() {
    file.Close()
}

const LoremIpsum = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n"

func Run(n int) {
    out := bufio.NewWriter(file)
    for i := 0; i < n; i++ {
        out.WriteString(LoremIpsum)
    }
    out.Flush()
}

Результат бенчмарка:

goos: linux
goarch: amd64
pkg: some/bench
cpu: 12th Gen Intel(R) Core(TM) i7-1260P
BenchmarkGoout-16              8         153202019 ns/op            4216 B/op          4 allocs/op
BenchmarkCOut-16               6         184548701 ns/op               2 B/op          0 allocs/op
PASS
ok      some/bench      2.668s

Вроде бы Го обгоняет? Но запустим ещё несколько раз

BenchmarkGoout-16              7         155197764 ns/op            4216 B/op          4 allocs/op
BenchmarkCOut-16               8         137439268 ns/op               0 B/op          0 allocs/op

BenchmarkGoout-16              9         151046499 ns/op            4216 B/op          4 allocs/op
BenchmarkCOut-16               8         173827842 ns/op               0 B/op          0 allocs/op

BenchmarkGoout-16              8         143111627 ns/op            4218 B/op          4 allocs/op
BenchmarkCOut-16               8         137793313 ns/op               2 B/op          0 allocs/op

BenchmarkGoout-16              8         158575604 ns/op            4216 B/op          4 allocs/op
BenchmarkCOut-16               8         169162607 ns/op               0 B/op          0 allocs/op

Видно, что результаты сравнимые. Поэтому я бы не стал говорить, что Go обгоняет Си на задаче с объемным выводом.

ВДОГОНКУ

Данные выше получены в WSL на виндовой партиции. Это, конечно, вносит долю неопределённости в результаты.

Для чистоты эксперимента прогнал на чисто линуксовой машине:

go test -benchmem -bench 'BenchmarkGoout|BenchmarkCOut' -benchtime=20x ./bench
goos: linux
goarch: amd64
pkg: some/bench
cpu: Intel(R) Xeon(R) CPU E5-2620 v4 @ 2.10GHz
BenchmarkGoout-32             20          14473849 ns/op            4216 B/op          4 allocs/op
BenchmarkCOut-32              20          18762605 ns/op               0 B/op          0 allocs/op

BenchmarkGoout-32             20          15798282 ns/op            4216 B/op          4 allocs/op
BenchmarkCOut-32              20          19778202 ns/op               0 B/op          0 allocs/op

BenchmarkGoout-32             20          15402149 ns/op            4216 B/op          4 allocs/op
BenchmarkCOut-32              20          19993675 ns/op               0 B/op          0 allocs/op

BenchmarkGoout-32             20          15215966 ns/op            4216 B/op          4 allocs/op
BenchmarkCOut-32              20          19658964 ns/op               0 B/op          0 allocs/op

BenchmarkGoout-32             20          15823734 ns/op            4216 B/op          4 allocs/op
BenchmarkCOut-32              20          20650712 ns/op               0 B/op          0 allocs/op

Го стабильно обгоняет на 30%.

ВДОГОНКУ2

Прогнал тесты в образе на базе Alpine Linux

С приложением ситуация аналогична: разница в производительности в три раза.

/bin/bash -c 'time echo 8 | ./go_app | wc -l'
100000000

real    0m0.609s
user    0m0.675s
sys     0m0.112s
/bin/bash -c 'time echo 8 | ./c_app | wc -l'
100000000

real    0m1.694s
user    0m1.800s
sys     0m0.344s

Бенчмарк для Го:

bench-1  | goos: linux
bench-1  | goarch: amd64
bench-1  | pkg: some/bench
bench-1  | cpu: 12th Gen Intel(R) Core(TM) i9-12900
bench-1  | BenchmarkGoToFile-24                       20           9211943 ns/op           65656 B/op          4 allocs/op
bench-1  | BenchmarkC_ToFile-24                       20           9816959 ns/op               0 B/op          0 allocs/op
bench-1  | BenchmarkGoToDevFile-24                    20          37064680 ns/op           65656 B/op          4 allocs/op
bench-1  | BenchmarkC_ToDevFile-24                    20          36987889 ns/op               0 B/op          0 allocs/op
bench-1  | BenchmarkGoToStdStream-24                  20          36894987 ns/op           65536 B/op          1 allocs/op
bench-1  | BenchmarkC_ToStdStream-24                  20          36866660 ns/op               0 B/op          0 allocs/op
bench-1  | PASS
bench-1  | ok   some/bench      3.501s
→ Ссылка
Автор решения: Serge3leo

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

Если, скажем, в начале main() добавить строки:

    #ifdef FLOCKFILE
        flockfile(stdout);
    #endif

То производительно вывода повысится. К примеру, на macOS 14.7 (Intel(R) Core(TM) i9-9880H CPU @ 2.30GHz), выигрыш около 25%.

Тестовый запуск немного видоизменён для улучшения стабильности, что бы не учитывать wc -l и прочего.

$ gcc-mp-14 -O3 temp2.c
$ echo 8 | time ./a.out > out
        4,86 real         4,61 user         0,22 sys
$ gcc-mp-14 -O3 -DFLOCKFILE temp2.c
$ echo 8 | time ./a.out > out
        3,61 real         3,39 user         0,21 sys

P.S.

Во FreeBSD/Linux есть функция fwrite_unlocked(), а для Windows _fwrite_nolock(), которые, хотя и нестандартны, но более эффективны, чем предварительный захват stdout.

P.P.S. Впрочем, для macOS, iOS и др. fwrite_unlocked() несложно реализовать на основе стандартной putc_unlocked().

→ Ссылка
Автор решения: avp

В ответе вариант с ускорением задачи только на Си (go и Rust на комп не ставил)).

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

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

Я попробовал заменить FILE *stdout на простой поток вывода с буферизаций O_File (код реализации см. ниже) и получил на данной задаче почти двукратное ускорение.

Потребовались минимальные изменения кода автора:

  • вызов fwrite(b, 1, n + 1, stdout); был заменен на f_write(out, b, n + 1);
  • добавлен один параметр в функцию search -- void search(O_File *out, char b[], int n, int i), соответствующие корректировки внесены в вызовы search;
  • в коде main создаем поток O_File out = f_init_w(1, buf, BSIZE);, связанный с открытым файловым дескриптором стандартного вывода и в конце main явно вызывается функция выталкивания буфера потока вывода f_flush(&out);

Для начала, результаты:

avp@avp-desktop:~/avp/hashcode$ gcc -DHAND_BUF_OUT f_write.c -O3
avp@avp-desktop:~/avp/hashcode$ time echo 8 | ./a.out | wc -l
100000000

real    0m0.819s
user    0m1.175s
sys 0m0.232s
avp@avp-desktop:~/avp/hashcode$ gcc  f_write.c -O3
avp@avp-desktop:~/avp/hashcode$ time echo 8 | ./a.out | wc -l
100000000

real    0m1.408s
user    0m2.008s
sys 0m0.238s
avp@avp-desktop:~/avp/hashcode$ 
avp@avp-desktop:~/avp/hashcode$ gcc -DHAND_BUF_OUT f_write.c -O3
avp@avp-desktop:~/avp/hashcode$ time echo 9 | ./a.out | wc -l
1000000000

real    0m7.587s
user    0m11.552s
sys 0m2.537s
avp@avp-desktop:~/avp/hashcode$ gcc  f_write.c -O3
avp@avp-desktop:~/avp/hashcode$ time echo 9 | ./a.out | wc -l
1000000000

real    0m14.376s
user    0m20.820s
sys 0m2.725s
avp@avp-desktop:~/avp/hashcode$ 

А вот сорсы (ветка #ifdef HAND_BUF_OUT реализация с "ручной буферизацией", другая ветка -- код из вопроса; размер буфера одинаковы в обеих ветках):

#ifdef HAND_BUF_OUT

#include <stdlib.h>
#include <unistd.h>
#include <stdio.h>
#include <string.h>

typedef struct O_File {
  int fd,
    flags;
  char *buf;
  size_t b_size,
    pos;
} O_File;


O_File
f_init_w (int fd, char *buf, size_t b_size)
{
  struct O_File f = {.fd = fd, .buf = buf, .b_size = b_size};

  return f;
}

ssize_t
i_write (int fd, void *buf, size_t count)
{
  ssize_t r = 0;
  
  while (count) {
    ssize_t l = write(fd, buf, count);

    if (l == count)
      return r + count;
    if (l < 0)
      return l;

    buf += l;
    r += l;
    count -= l;
  }

  return r;
}

ssize_t
f_flush (O_File *f)
{
  if (f->flags == EOF)
    return EOF;
  
  ssize_t r = i_write(f->fd, f->buf, f->pos);
  if (r < 0)
    f->flags = EOF;

  f->pos = 0;
  return r;
}

ssize_t
f_write (O_File *f, void *buf, size_t count)
{
  ssize_t ret = count;

  if (f->pos + count <= f->b_size) {
    memcpy(f->buf + f->pos, buf, count);
    f->pos += count;
  } else {
    ssize_t r = f_flush(f);
    if (r < 0)
      return r;

    if (count > f->b_size) {
      if ((r = i_write(f->fd, buf, count)) < 0) {
        f->flags = EOF;
        return r;
      }
    } else {
      memcpy(f->buf, buf, count);
      f->pos += count;
    }
  }

  return ret;
}


void search(O_File *out, char b[], int n, int i) {
    if (i == n) {
      f_write(out, b, n + 1);
    } else {
      for (char d = '0'; d <= '9'; ++d) {
        b[i] = d;
        search(out, b, n, i + 1);
      }
    }
}

#define BSIZE (64 * 1024)

int main() {
    char *buf = malloc(BSIZE);
    if (buf == NULL) {
        return 1;
    }

    O_File out = f_init_w(1, buf, BSIZE);
    /*    
    if (setvbuf(stdout, buf, _IOFBF, BSIZE) != 0) {
        return 1;
    }
    */
    
    int n;
    if (scanf("%d", &n) != 1) {
        return 1;
    }

    char *b = malloc((size_t)n + 1);
    if (b == NULL) {
        return 1;
    }
    b[n] = '\n';
    search(&out, b, n, 0);

    f_flush(&out);
}


#else

#include <stdio.h>
#include <stdlib.h>

#define BSIZE (64 * 1024)

void search(char b[], int n, int i) {
    if (i == n) {
        fwrite(b, 1, n + 1, stdout);
    } else {
        for (char d = '0'; d <= '9'; ++d) {
            b[i] = d;
            search(b, n, i + 1);
        }
    }
}

int main() {
    char *buf = malloc(BSIZE);
    if (buf == NULL) {
        return 1;
    }

    if (setvbuf(stdout, buf, _IOFBF, BSIZE) != 0) {
        return 1;
    }

    int n;
    if (scanf("%d", &n) != 1) {
        return 1;
    }

    char *b = malloc((size_t)n + 1);
    if (b == NULL) {
        return 1;
    }
    b[n] = '\n';
    search(b, n, 0);
}

#endif

P.S. Если найдутся желающие развить этот набросок до stdio -- welcome.

P.P.S. Выводы я делать не хочу, предлагаю каждому подумать самому над состоянием индустрии...

→ Ссылка
Автор решения: Stanislav Volodarskiy

NB Поставьте плюсик ответу avp. Я пересказываю его идею, только используя стандартный C.

С оказывается медленнее Go из-за особенностей стандартной библиотеки.

В C

  1. буфер для файла встроен в сам файл;
  2. функция fwrite по стандарту обязана работать в многопоточной среде.

Чтобы не допускать гонку fwrite использует блокировку (пример исходного кода). Если вызвать её миллиард раз, она миллиард раз блокирует файл, что требует времени.

В Go

  1. буфер – отдельный объект;
  2. буфер не обязан работать в многопоточной среде.

Вызов buffer.Write ничего не блокирует, только переписывает данные в буфер. Когда буфер заполняется, он выполняет запись в файл и эта запись синхронная, то есть какая-то блокировка есть. Но в случае с C блокировка вызывается каждые десять байт (это длина одной строки), а в случае с Go – каждые 64KB, то есть в шесть с половиной тысяч раз реже. Что и позволяет Go обойти C.

Чтобы подтвердить теорию практикой, я написал для C буфер. Это полностью стандартный C, данные по-прежнему записываются вызовами fwrite, но эти вызовы пишут каждый по 64KB данных за раз.

Сама программа мало изменилась:

#include <stdio.h>
#include <stdlib.h>

#include <buffer_t.h>

#define BSIZE (64 * 1024)

static buffer_t bufout;

static void search(char b[/* n + 1 */], int n, int i) {
    if (i == n) {
        bwrite(&bufout, (size_t)n + 1, b);
    } else {
        for (char d = '0'; d <= '9'; ++d) {
            b[i] = d;
            search(b, n, i + 1);
        }
    }
}

int main() {
    if (!binit(&bufout, BSIZE, stdout)) {
        return 1;
    }

    int n;
    if (scanf("%d", &n) != 1) {
        return 1;
    }

    char *b = malloc((size_t)n + 1);
    if (b == NULL) {
        return 1;
    }
    b[n] = '\n';
    search(b, n, 0);

    bflush(&bufout);
    bterm(&bufout);
}

Заголовочный файл буфера:

#ifndef BUFFER_T_H_
#define BUFFER_T_H_

#include <stdbool.h>
#include <stdio.h>

typedef struct {
    size_t size;
    size_t pos;
    unsigned char *data;
    FILE *file;
} buffer_t;

bool binit(buffer_t *buffer, size_t size, FILE *file);
void bterm(buffer_t *buffer);
bool bflush(buffer_t *buffer);
bool bwrite(buffer_t *buffer, size_t size, void *data);

#endif

Реализация буфера:

#include <buffer_t.h>

#include <stdlib.h>
#include <string.h>

bool binit(buffer_t *buffer, size_t size, FILE *file) {
    unsigned char *data = malloc(size);
    if (data == NULL) {
        return false;
    }
    buffer->size = size;
    buffer->pos = 0;
    buffer->data = data;
    buffer->file = file;
    return true;
}

void bterm(buffer_t *buffer) {
    free(buffer->data);
    buffer->file = NULL;
    buffer->data = NULL;
    buffer->pos = 0;
    buffer->size = 0;
}

bool bflush(buffer_t *buffer) {
    size_t written = fwrite(buffer->data, 1, buffer->pos, buffer->file);
    if (written < buffer->pos) {
        return false;
    }
    buffer->pos = 0;
    fflush(buffer->file);
    return true;
}

bool bwrite(buffer_t *buffer, size_t size, void *data) {
    while (size > 0) {
        size_t space = buffer->size - buffer->pos;
        if (size <= space) {
            memcpy(buffer->data + buffer->pos, data, size);
            buffer->pos += size;
            return true;
        }
        if (buffer->pos == 0) {
            return fwrite(data, 1, size, buffer->file) == size;
        }
        memcpy(buffer->data + buffer->pos, data, space);
        buffer->pos += space;
        size -= space;
        data = (unsigned char *)data + space;
        if (!bflush(buffer)) {
            return false;
        }
    }
    return true;
}

Для обоих тестов C с буфером на четверть обгоняет Go. Статус кво восстановлен:

$ time echo 8 | ./standard-c | wc -l
100000000

real  0m2.165s
user  0m2.964s
sys   0m0.208s

$ time echo 8 | ./buffered-c | wc -l
100000000

real  0m1.025s
user  0m1.560s
sys   0m0.312s

$ time echo 8 | ./standard-go | wc -l
100000000

real  0m1.453s
user  0m2.108s
sys   0m0.204s

$ time echo 9 | ./standard-c | wc -l
1000000000

real  0m21.429s
user  0m29.052s
sys   0m2.624s

$ time echo 9 | ./buffered-c | wc -l
1000000000

real  0m11.316s
user  0m17.980s
sys   0m3.492s

$ time echo 9 | ./standard-go | wc -l
1000000000

real  0m15.540s
user  0m23.400s
sys   0m2.296s

P.S. Код лежит в репозитории c-go-stdout-performance. Убедитесь сами.

→ Ссылка