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 шт):
Это не ответ, а развёрнутый комментарий.
Я сделал пример бенчмарка, который выводит в файл 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()
}
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
Программа однопоточная, стало быть синхронизацию доступа к 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()
.
В ответе вариант с ускорением задачи только на Си (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. Выводы я делать не хочу, предлагаю каждому подумать самому над состоянием индустрии...
NB Поставьте плюсик ответу avp. Я пересказываю его идею, только используя стандартный C.
С оказывается медленнее Go из-за особенностей стандартной библиотеки.
В C
- буфер для файла встроен в сам файл;
- функция
fwrite
по стандарту обязана работать в многопоточной среде.
Чтобы не допускать гонку fwrite
использует блокировку (пример исходного кода). Если вызвать её миллиард раз, она миллиард раз блокирует файл, что требует времени.
В Go
- буфер – отдельный объект;
- буфер не обязан работать в многопоточной среде.
Вызов 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. Убедитесь сами.