
Комментарии 13
Бумага Железо все стерпит!
Память нынче не та. И это не временный тренд, это с нами надолго
«Не плати за то, что не используешь»
очевидно вы платите за удобство и функционал, который предоставляет . Было бы интереснее посмотреть на проектирование и реализацию всего что позволяет . И сравнение по итогу.
А так… тут напрашивается хотя бы сравнение с {fmt} (в официальном readme есть пара бенчмарков)
Отчего ж вы не заглянули внутрь исполняемых файлов и детально не разобрались, откуда берутся эти самые (мега)байты? Там же относительно несложно всё. Очевидно, что для того, чтобы вывести что-то на экран, таки придётся позвать из ядра GetStdHandle, WriteConsole, и так далее до выхода через ExitProcess.
Вот если на асме:
EUROASM
hello PROGRAM Format=PE, Entry=Start, IconFile=
INCLUDE winapi.htm
Start: nop
StdOutput =B"Hello, Habr"
TerminateProgram
ENDPROGRAMИ StdOutput и TerminateProgram тут макросы, и если всё дизассемблировать, то будет 80 строк на ассебмлере, вот как на самом деле выглядит вывод в консоль:
Привет, Хабр!
401000 public start
401000 start proc near
401000 nop
401001 push 0
401003 push offset aHelloHabr ; "Hello, Habr"
401008 push 0FFFFFFFFh
40100A push 0FFFFFFF5h
40100C call sub_401018
401011 push 0
401013 call ExitProcess
401013 start endp
401013
401018 ; =============== S U B R O U T I N E ====================
401018 sub_401018 proc near ; CODE XREF: start
401018 var_28 = dword ptr -28h
401018
401018 pusha
401019 mov ebp, esp
40101B sub esp, 8
40101E mov [esp+28h+var_28], esp
401021 push dword ptr [ebp+24h]
401024 call GetStdHandle
401029 mov ebx, eax
40102B inc eax
40102C stc
40102D jz short loc_401099
40102F mov ecx, [ebp+28h]
401032 mov edi, [ebp+2Ch]
401035 mov eax, [ebp+30h]
401038 mov edx, offset WriteFile
40103D test al, 2
40103F jz short loc_40104F
401041 mov edx, offset WriteConsoleA
401046 test al, 1
401048 jz short loc_40104F
40104A mov edx, offset WriteConsoleW
40104F loc_40104F:
40104F test al, 4
401051 jz short loc_401065
401053 lea edi, [ebp-4]
401056 mov dword ptr [edi], 0A000Dh
40105C test al, 1
40105E jnz short loc_401065
401060 mov word ptr [edi], 0A0Dh
401065 loc_401065:
401065 xor eax, eax
401067 mov esi, edi
401069 test byte ptr [ebp+30h], 1
40106D jz short loc_40107A
40106F shr ecx, 1
401071 repne scasw
401074 jnz short loc_40107F
401076 dec edi
401077 dec edi
401078 jmp short loc_40107F
40107A loc_40107A:
40107A repne scasb
40107C jnz short loc_40107F
40107E dec edi
40107F loc_40107F:
40107F sub edi, esi
401081 mov eax, [ebp+30h]
401084 and al, 3
401086 xor al, 3
401088 jnz short loc_40108C
40108A shr edi, 1
40108C loc_40108C:
40108C push 0
40108E push dword ptr [ebp-8]
401091 push edi
401092 push esi
401093 push ebx
401094 call edx ; WriteFile
401096 cmp [ebp-8], edi
401099 loc_401099:
401099 mov esp, ebp
40109B popa
40109C retn 10h
40109C sub_401018 endpЗанимает этот исполняемый файл 2596 байт, и там на самом деле не только start().
Ну а дальше берётся любой анализатор заголовка и смотрится куда утекают оставшиеся байты (80 строк на ассемблере обычно не занимают два килобайта):

На более высокоуровневом языке конечно будет пожирнее, если на Расте, то 124898 байт. Но и этим килобайтам есть рациональное объяснение, само собой, там уже обработка исключений и много чего до кучи:

Я пытался сделать обзор проблемы именно с точки зрения разработчика С++, а не как ревер-инженер на ASM. Целью этого мини-исследования было именно практически добиться минимально возможного размера компилятором С++ (в данном случае GCC) и показать развитие тенденции вместе с обновлением компилятора, конкретное содержание PE заголовков для этого мне показалось излишним. Хотя это вполне имеет место быть, и спасибо вам за дополнение!
Проблема в том, что более высокоуровневые языки почему-то не выкидывают лишнее в очевидных ситуациях, когда исключения попросту не требуются. Например компилятор GCC догадывается не прикручивать исключения для Hello Worldно как только в коде появляется malloc()/free() компилятор уже может их включить, и тут уже нужно явно указывать флаг что стоит отключить добавление механизма исключений.
В вашем примере для того чтобы добиться размера меньше, вам уже пришлось использовать ASM, что уже не совсем разработка на С++ :)
Компилятор по идее для того и нужен чтобы упростить жизнь разработчику и не заставлять его писать на ASM из-за того что компилятор не справился со своей задачей лучше программиста.
Компилятор по идее для того и нужен чтобы упростить жизнь разработчику и не заставлять его писать на ASM из-за того что компилятор не справился
Тут я согласен по части "упростить", но не очень по части "не справился", ведь прогресс тоже на месте не стоит, и количество зависимостей растёт, и аккуратно избавиться от них или уменьшить бывает непросто, да и к чему, ведь количество памяти тоже? Я начинал программировать больше тридцати лет назад на ДВК, у меня было 56 килобайт оперативной памяти (и, кстати, там была RT11FB - это Foreground/Background операционка, я запускал там две задачи для дифрактометра - одна, на ассемблере Macro-11 снимала данные, а на второй (на Си) оператор мог прошлые обсчитывать, всё параллельно), и надо было программировать "экономно" ценой долгих вечеров в лаборатории. Но эти времена прошли, хотя, конечно есть "перегибы". Даже если взять Раст, у которого с кодогенерацией всё более-менее норм, то текстовый редактор Zed, на нём написанный, тянет за собой под две тысячи зависимостей, в число которых входят Фурье, MP3 и даже Flac, а исполняемый файл занимает отнюдь не два, а триста пятьдесят мегабайт, при этом довольно резво запускается (чуть быстрее чем VS Code), так как интерфейс там не на электроне, а собственный gpui. Обычно при выпуске продукта конечно имеет смысл оценить используемые ресурсы с точки зрения дискового пространства, занимаемой памяти и общей производительности, но некоторые оптимизации могут довольно "дорого" стоить. А компиляторы современные неплохие, особенно интеловский на своих процессорах показывает очень неплохую производительность (кстати, там объём исполняемого файла и библиотек также растёт за счёт раздельного кода под раздельные архитектуры типа AVX2/AVX512 и это норм). Оптимизировать надо "бутылочные горлышки", при этом объём кода может даже вырасти при том же разворачивании циклов, и современные компиляторы с этим справляются.
[ Я совершено не оспариваю неявное утверждение о том, что iostream в C++ это полнейшее "гуано". ]
Вопрос лишь - в чем, собственно, смысл делать сборку без динамических/разделяемых библиотек (флаг -static который при сборке передается в ld)? Если Вы строите бинарник для встроенных приложений (микроконтроллеров, систем реального времени) то C++ Standard Library вам точно не нужна поскольку эта библиотека (по умолчанию, если Вы не используете специальные аллокаторы) динамически аллокирует/освобождает память. Во всех осталных случаях удобнее использовать разделяемые библиотеки.
Если уберете этот флаг и скомпилируете:
g++ -O2 hello.cpp -o hello.exeТо результат будет совсем иной:
ls -al
total 39
drwxr-xr-x 2 xxx yyy 4 May 8 15:23 .
drwxr-xr-x 8 xxx yyy 8 May 8 15:05 ..
-rw-r--r-- 1 xxx yyy 75 May 8 15:05 hello.cpp
-rwxr-xr-x 1 xxx yyy 18192 May 8 15:23 hello.exe
ldd hello.exe
linux-vdso.so.1 (0x00007ffed21c3000)
libstdc++.so.6 => /lib64/libstdc++.so.6 (0x00007f4b17c00000)
libm.so.6 => /lib64/libm.so.6 (0x00007f4b17b25000)
libgcc_s.so.1 => /lib64/libgcc_s.so.1 (0x00007f4b17eca000)
libc.so.6 => /lib64/libc.so.6 (0x00007f4b17800000)
/lib64/ld-linux-x86-64.so.2 (0x00007f4b17ef0000)
nm hello.exe
0000000000403de0 d _DYNAMIC
0000000000404000 d _GLOBAL_OFFSET_TABLE_
0000000000401140 t _GLOBAL__sub_I_main
0000000000402000 R _IO_stdin_used
w _ITM_deregisterTMCloneTable
w _ITM_registerTMCloneTable
U _ZNKSt5ctypeIcE13_M_widen_initEv@GLIBCXX_3.4.11
0000000000401260 W _ZNKSt5ctypeIcE8do_widenEc
U _ZNSo3putEc@GLIBCXX_3.4
U _ZNSo5flushEv@GLIBCXX_3.4
U _ZNSt8ios_base4InitC1Ev@GLIBCXX_3.4
U _ZNSt8ios_base4InitD1Ev@GLIBCXX_3.4
U _ZSt16__throw_bad_castv@GLIBCXX_3.4
0000000000404080 B _ZSt4cout@GLIBCXX_3.4
0000000000404191 b _ZStL8__ioinit
U _ZStlsISt11char_traitsIcEERSt13basic_ostreamIcT_ES5_PKc@GLIBCXX_3.4
0000000000402118 r __FRAME_END__
000000000040201c r __GNU_EH_FRAME_HDR
0000000000404060 D __TMC_END__
000000000040037c r __abi_tag
000000000040405c B __bss_start
U __cxa_atexit@GLIBC_2.2.5
0000000000404058 D __data_start
0000000000401220 t __do_global_dtors_aux
0000000000403dd8 d __do_global_dtors_aux_fini_array_entry
0000000000402008 R __dso_handle
0000000000403dc8 d __frame_dummy_init_array_entry
w __gmon_start__
U __libc_start_main@GLIBC_2.34
00000000004011a0 T _dl_relocate_static_pie
000000000040405c D _edata
0000000000404198 B _end
0000000000401264 T _fini
0000000000401000 T _init
0000000000401170 T _start
0000000000404190 b completed.0
0000000000404058 W data_start
00000000004011b0 t deregister_tm_clones
0000000000401250 t frame_dummy
00000000004010b0 T main
00000000004011e0 t register_tm_clones
Как только вы начинаете полагаться на то что libstdc++ будет предустановлена в системе, есть риск, что на другом компьютере вы можете столкнуться c
Запуск программы невозможен, так как на компьютере отсутствует libstdc++.dll
Или какой-либо другой внешней библиотеки на которую вы полагаетесь. Это уменьшает переносимость и вынуждает качать внешние зависимости.
ldd hello.exe
linux-vdso.so.1 (0x00007ffed21c3000)
libstdc++.so.6 => /lib64/libstdc++.so.6 (0x00007f4b17c00000)
libm.so.6 => /lib64/libm.so.6 (0x00007f4b17b25000)
libgcc_s.so.1 => /lib64/libgcc_s.so.1 (0x00007f4b17eca000)
libc.so.6 => /lib64/libc.so.6 (0x00007f4b17800000)
/lib64/ld-linux-x86-64.so.2 (0x00007f4b17ef0000)В вашем примере вы полагаетесь на то, что в системе будут и libstdc++ и libc и libm и еще от компилятора libgcc_s.
Если хоть чего-то из этого не будет, или же будет не той версии которой нужно (привет от GLIBC) - ваш файл превращается в тыкву. Он не запуститься в среде с musl или же где нет компилятора с его libgcc_s.so.
Во-первых и прежде всего, вы непонятно чего хотите добиться.
Во-вторых, есть ощущение, что вы это делаете неправильно.
Вы зачем-то смело прыгнули в "nostdlib", но при этом не используете оптимизацию линкера!
Никогда ни в коем случае не нужно на десктопе использовать "nostdlib" и писать свои CRT. Там всё намного-намного сложнее, чем кажется на первый взгляд. Вы совершенно точно что-то неуловимо поломаете и потом месяцами будете ошибки ловить.
Вы уверены, что ваш куцый CRT корректно отрабатывает работу с исколючениями и все имеющиеся в stdlib способы инициализации/деинициализации?
А математика вся во всех случаях будет работать? Точно-точно? А где тогда тонкая настройка компилятора?
У вас же в такой конфигурации масса всего работать не будет (или что хуже - будет работать некорректно) - от многопоточности, до особенностей работы с плавающей точкой и выделением памяти!
И, кстати, почему для начала не отключили исключения в настройках компилятора? Это сразу бы уменьшило размер файла, без вот этого всего. Всё равно вы практически наверняка поломали их, выкинув стандартный startup.
И ещё раз: почему вы даже не посмотрели в стону оптимизации линкером, которая во многом и была придумана, чтобы решить вашу "проблему": lto и тому подобное?
Я три раза портировал stdlib и crt на новые платформы, много раз писал на C++ под "голое железо" и поэтому повтрю: совершенно абсолютно никогда так не делайте на десктопе!!! Такие оптимизации требуют глубочайшего знания особенностей реализации crt и stdlib на конкретной платформе, компиляторе и линкере! На десктопе это применяется только для загрузчиков исполняемых файлов и ядра ОС, в который масса сил тратится на обеспечение совместимости с кокретными версиями компиляторов, линкеров и платформ!
И даже на микроконтроллерах я видел только две "любительские" (не поддержанные разработчиками контроллеров, компилятора или какого-то очень большого проекта) корректные реализации ctr. Подавляющее большинство реализаций содержат грубые ошибки, который авторы не видят только потому, что ещё не использовали в коде поломанные ими функции.
Я упоминал, что:
И, собственно всё. Иные флаги оптимизаций не влияют на размер при текущей кодовой базе.
То есть ни -flto/-flto=thin, ни -fno-exceptions ни даже специальный флаг GCC -fwhole-program не помогли выкинуть лишнее в случае включения iostream. Видимо линкер считает все его зависимости используемыми.
С printf также, линкер даже со всеми навешенными оптимизациями может просто не найти неиспользуемые символы чтобы их выкинуть.
Оптимизации линкера очень хороши, но они бессильны если из конкретной сборки стандартной либы физически выкидывать нечего.
Что на счет отказа от CRT - я это использовал в качестве сравнительного примера именно для Hello World и только под Windows.
Однако для проектов посложнее, однозначно что этот путь будет крайне тернист и использование стандартного CRT будет куда выгоднее.
Я даже не стал приводить приводить примеров самопального CRT для Linux, т.к. там уже понадобились бы прямые ассемблерные вставки, что уже требует специализаций под конкретную архитектуру, а там уже есть чему ломаться. У части пользователей это могло бы просто не запуститься или что еще хуже выполнить это некорректно. Тут рациональнее использовать статическую линковку с musl заместо glibc.
C++: Как мы докатились до Hello World в 2 МБ