1. 65
    1. 18

      I found this insightful. I hadn’t realized Carbon was specifically created out of frustration with the C++ process and definitely would not have expected them to say it so explicitly.

      I do wonder if the two factions can even further be simplified:

      • Faction 1: Can rebuild all of their code (libraries and executables) from scratch when needed
      • Faction 2: Can’t

      An ABI breaking change would at least be feasible for Faction 1. Whereas, Faction 2 obviously would be less confident to be able to make it work.

      1. 12

        The split presented in the article isn’t the only split, and the committee notion that there are no dialects has been fiction for a very long time.

        On the language level, there is the split of disabling exceptions and RTTI and the other split of disabling type-based alias analysis.

        Then there are projects that avoid the standard library and have their own strings and collections.

        I’d be interested in seeing a survey of notable C++ code bases documenting what they do on these points.

        1. 8

          On the language level, there is the split of disabling exceptions and RTTI and the other split of disabling type-based alias analysis.

          This is the one that annoys me the most. Exceptions and RTTI are simply too big for embedded systems. A stack unwinder and exception implementation takes about as much code as we have total memory. There’s simply no way that we can enable these features, yet the standard pretends they’re universal and introduces APIs that we simply can’t use safely because, without exceptions, they report error by calling abort.

          On the standard library front, there is a defined subset: freestanding. This is a stupid subset because it’s specified as requiring a tiny subset of the standard library, but making everything else optional. Why does this matter? Because, when we ship a freestanding environment, we can ship a load of things from the hosted environment. But people can’t depend on them for portability between embedded environments.

          1. 8

            Whats your take on https://www.youtube.com/watch?v=BGmzMuSDt-Y ? This is a pretty deep dive inside exception handling and its relationship with embedded platforms with a few surprising takeaways:

            • Exception handling reduces code size past a certain amount of code (specifically past a certain amount of call-to-function-that-can-fail)
            • Existing exception handling implementations are far from optimal both from a runtime and binary size standpoint
            1. 4

              I don’t watch YouTube videos, but, from your summary:

              You have to handle errors somewhere. Your choices are basically write explicit branches or emit some metadata that lets you factor it out. DWARF unwind metadata is big but it should be possible to build something smaller and that may be smaller than the branch instructions. This is probably more true in places where the error is not handled in the caller, because some metadata for unwinding can be smaller than branches to a load of function epilogue.

              The Itanium ABI requires heap allocation to throw exceptions, which is impossible for embedded (out of memory is the most common cause of exceptions). The common alternative is SEH, but that also wouldn’t work for us because we need to handle out-of-stack and that requires destructive unwinding. It might be possible to build a combination.

              We do implement something like old-style SEH, with a linked list of on-stack activation records, but the stack overhead if we did that for every function with cleanups would be too large. It’s only feasible because our ABI has two callee-save GPRs, and even then we need to use it sparingly.

              For embedded systems where you have a few MiBs of RAM, the code size of the unwinder is probably amortised.

              Doing it with C++ exceptions is complex because exceptions are built with RTTI and the type info object has a name method, which must return a C string that contains a unique name. The Itanium ABI returns a mangled name. It could also return a short unique ID, which could be much smaller, but you’re still looking at tens of bytes for each thrown or caught type, which can quickly add up.

              1. 3

                I don’t watch YouTube videos,

                <3

                I have to remember this response. I love it.

                1. 2

                  May I recommend: https://www.youtube-transcript.io/videos/BGmzMuSDt-Y (And try the summary button.)

                  It’s on my todo list to make a CLI tool that does this.

                  (The summary doesn’t replace the transcript, but it’s a signal whether you should try reading the transcript.)

                  1. 3

                    Interesting. Thanks for that.

                    Machine-generated transcripts are not terribly readable and as a speedreader that does matter to me, quite a lot. I can’t zip through a verbatim transcript like I can even a 1:1 human-edited one. Better than nothing, though.

                    I can’t remember what it was but some tech site recently published a 1-hour video with a hand-edited transcript. I timed it; I think it took me about 7 or 9 minutes to read, and I wasn’t hurrying. If hurrying I could probably halve that again.

                    A little while ago, Charlie Stross posted something on Mastodon saying most people read about the speed that they speak. Roughly 250wpm. That really shocked me. I think my “gentle unhurried reading for pleasure” speed is ~4x that. That’s why I don’t watch videos.

                    1. 2

                      Same here. I don’t have a great solution. If it seems interesting, I usually put the video speed on 2X, turn on the transcript, and click through it to catch the slides.

                      1. 2

                        I don’t have a great solution.

                        No, me neither.

                        I tend to tell people I really don’t like videos and ask for other formats. As a general statement – obviously, there are exceptions – if it’s not available as text, it’s not worth the time anyway.

                        This is why I admire David’s flat statement here.

                2. 3

                  I rewatched it as it is a very engaging presentation. It focuses on unwind table based exception handling.

                  The main point:

                  • Exception handling code size grows with the number of functions (plus the fixed size overhead of the unwinder)
                  • Error code handling code size grows with the number of error-returning function calls

                  In typical code, the number of function calls is much greater than the number of functions, and as the code grows the overhead of the unwinder gets dwarfed by the other terms and eventually error code handling becomes more expensive in terms of code size. This is even more true in embedded where functions are even less likely to require an unwind table as dynamic allocations being banned leads to a bigger proportion of trivially destructible types.

                  Regarding the usual points against exception in embedded:

                  • Exception handling functions from the ABI are replaceable, you can write your own allocation function for exceptions that uses a custom static buffer, no need for actual free store.
                  • Most of the overhead people typically see when enabling exception is the default terminate handler implementation pulling iostream, providing your own terminate handler fixes this.
                  • libunwind adds 8kb, but the presenters own implementation is around 3kb (on ARM 32bit)
                  • -fno-rtti does the right thing, it removes RTTI information except the ones required for exception handling
                  • unwinding tables can be more compact than the equivalent assembly code because its a much simpler bytecode
                  1. 1

                    Exception handling functions from the ABI are replaceable, you can write your own allocation function for exceptions that uses a custom static buffer, no need for actual free store

                    That’s probably true in the general case, for us it wouldn’t fit with our threat model. There’s also the problem that C++ allows nested exceptions (so the number of in-flight exceptions is unbounded) and throwing arbitrary types (so the size of a single exception is unbounded). You can probably work around these.

                    Most of the overhead people typically see when enabling exception is the default terminate handler implementation pulling iostream, providing your own terminate handler fixes this.

                    That seems unlikely. At least on the big systems I most commonly use, I wrote that handler. It’s in libcxxrt and has no dependencies on the C++ standard library at all. I believe the same is true for both libsupc++ and libc++abi.

                    The C++ personality function and associated machinery was a few tens of KiBs, last time I measured it,

                    libunwind adds 8kb, but the presenters own implementation is around 3kb (on ARM 32bit)

                    That seems very low. Last time I tried a stand-alone build, it was over 100 KiB for x86-64. A smaller one might be possible.

                    unwinding tables can be more compact than the equivalent assembly code because its a much simpler bytecode

                    I’d possibly buy this, though DWARF unwind tables are not small. Simply enabling exceptions on large codebases typically increases binary size by around 20%. I don’t think my error-handling code paths are 20% of the code size.

                3. 2

                  The surprising thing to me was that 90% of the savings (or something like that) was just overriding the terminate handler, all because the default one pulled in iostream to print the message! With that fix, turning exceptions on added only 10k or so of code size on ARM. I may give this a try in my next embedded project.

                  1. 1

                    I watched this with my boyfriend a few weeks ago and we were blown away by the findings. Highly recommend this to all C++ devs pessimistic about exceptions on embedded!!

                  2. 5

                    They’re too big for non-embedded systems. Google bans them on it’s very large C++ servers. At scale you have to count bytes as closely as you do in embedded.

                    1. 7

                      As that link makes clear, it bans them for primarily other reasons though, and it seems unlikely that they would if size were the only downside.

                      1. 6

                        A friend of mine removed a small static allocation (on the order of 1k I think) from a library that’s widely used within Google and the amount of memory he saved across the fleet was mind-blowing. Another friend wanted to do climate change work and seriously considered joining the Google core libraries team because at scale any optimization you can make has significant impact on power consumption.

                        There are obviously reasons other than resource usage to make choices, but they’re a real factor at scale.

                        1. 16

                          Is there a Jevon’s paradox issue with working for Google: once you free up 1% of performance by fixing some library, they will turn around and spend it on some LLM feature no one asked for?

                          1. 1

                            In theory no - I think they have pretty good measurements on performance regressions. In practice, given LLM enthusiasm, who knows…

                4. 9

                  Carbon was specifically created out of frustration with the C++ process

                  Carbon and Abseil, though Abseil is older. There are choices in the C++ standard library that have turned out to be poor. I forget off the top of my head, but IIRC, especially in collection types there are some mistakes that can’t be fixed without breaking ABI that have significant performance implications. So if you can rebuild all your code and care about performance you end up rewriting parts of the standard library…

                  1. 4

                    ABI breaking changes in C or C++ are disruptive and expensive if you are distributing binaries to customers. Even if you are using rust to build the executables you are shipping to customers, those rust executables are implicitly relying on the ABI stability of C/C++ because they are dynamically linking to C/C++ libraries in order to access system services.

                    1. 16

                      As a general rule ABI-breaking changes are only disruptive if you’re linking against pre-built code, such as closed-source libraries licensed from a vendor. Otherwise the ABI doesn’t matter that much, either you statically link or just ship the DLLs as part of your product.

                      C doesn’t need to worry very much about ABIs because the language’s design encourages APIs that hew closely to the underlying platform ABI (raw pointers, fixed-size struct layouts, and such), so you don’t have to care about details like the layout of FILE. As long as the API you’re providing is stable (which might take some care, but isn’t too tricky) you can link to C code compiled 20 years ago without worries.

                      Rust doesn’t need to worry about ABIs because most of its libraries are distributed as source code, and for the few libraries distributed as object code the language has good built-in support for exposing a language-neutral ABI-stable interface for easy invocation via FFI. Linking to system libraries isn’t a problem because either the system’s vendor knows how to produce ABI-stable shared objects (Windows, macOS) or the platform doesn’t have system libraries to begin with (Linux – the kernel ABI is stable, regardless of what the basket-case GNU libc might be doing).

                      The reason ABIs are a problem for C++ in particular is that (1) the standard library relies so heavily on templates and value types, and (2) there’s a culture of distributing proprietary libraries as object code. Together, these properties result in dependencies that directly expose the ABI of the C++ standard library they were compiled against.

                      1. 10

                        As a general rule ABI-breaking changes are only disruptive if you’re linking against pre-built code, such as closed-source libraries licensed from a vendor. Otherwise the ABI doesn’t matter that much, either you statically link or just ship the DLLs as part of your product.

                        The applicability of this varies a lot depending on the domain. Two different server apps may share little code beyond the platform’s standard libraries and so shipping everything is fine. They may even be built as container images and so can simply dynamically link a specific version of a shared library. The code sharing is small.

                        In desktop apps, this is far less true. GUI frameworks are large. The last time I checked, the set of libraries that every KDE app linked was over 100 MiB. And that was over a decade ago. On macOS, the platform provides a load of

                        Rust doesn’t need to worry about ABIs because most of its libraries are distributed as source code, and for the few libraries distributed as object code the language has good built-in support for exposing a language-neutral ABI-stable interface for easy invocation via FFI

                        Or, rather, Rust doesn’t need to worry about ABIs because the percentage of Rust code in a large application is small in comparison to the amount of C/C++ code and Rust uses the stability of the C ABI for any interoperability with shared libraries. Swift went the same route initially (using the C and Objective-C ABIs for interoperability), which let them iterate quickly, but by the time Swift was mature enough for people to want to ship libraries in Swift they had to fix it (and did, quite nicely).

                        Linking to system libraries isn’t a problem because either the system’s vendor knows how to produce ABI-stable shared objects (Windows, macOS) or the platform doesn’t have system libraries to begin with (Linux – the kernel ABI is stable, regardless of what the basket-case GNU libc might be doing).

                        The vendor’s libraries typically use the C and/or C++ ABIs (on Windows, COM is used instead of C++). The dig at glibc is misplaced given that glibc has used symbol versioning to provide ABI stability for 20+ years. But most software packaged by Linux distros has a lot more dependencies than either. Things like ICU, GTK/Qt, and so on are all big and need to provide stable ABIs or require recompilation.

                        The reason ABIs are a problem for C++ in particular is that (1) the standard library relies so heavily on templates and value types, and (2) there’s a culture of distributing proprietary libraries as object code. Together, these properties result in dependencies that directly expose the ABI of the C++ standard library they were compiled against.

                        Yes and no. Templates, as traditionally implemented (compile-time reification) expose a lot of ABI surface across libraries. Swift addressed this by doing compile-time reification of generics within a library and dynamic dispatch across library boundaries. You can implement the same yourself in C++, but it’s work.

                        The real problem for C++ (and, to a lesser extent, for C) is that the standard has a notion of a compilation unit but no notion of a library. There’s no language-level abstraction to hang ABI guarantees on. No one cares about the ABI within a compilation unit (and compilers will happily change the calling convention for static functions). No one should care about the ABI within a library, but in C++ anything that’s outside of an anonymous namespace may be referenced in other shared libraries. Modules might have fixed this if they actually worked.

                        1. 5

                          GUI frameworks are large. The last time I checked, the set of libraries that every KDE app linked was over 100 MiB. And that was over a decade ago.

                          *shrug* I don’t consider 100 MB to be large, the Slack desktop application is 490 MB and it uses 10x that in RAM to render a long scrollback. The disk and memory savings from shared libraries were important 20 years ago, but nowadays there are individual command-line tools that are larger than Gtk+ and Qt put together.

                          The dig at glibc is misplaced given that glibc has used symbol versioning to provide ABI stability for 20+ years. But most software packaged by Linux distros has a lot more dependencies than either.

                          The symbol versioning is part of the problem with GNU libc. I can’t compile a dynamically-linked C binary on Ubuntu 24.04 and expect it to run on Ubuntu 16.04, because it’ll try to resolve libc.so symbols that don’t exist in the older version. This problem doesn’t exist when statically linking with musl.

                          The most popular Linux distribution in the world, Android, requires packaged applications to bundle their dependencies.

                          Things like ICU, GTK/Qt, and so on are all big and need to provide stable ABIs or require recompilation.

                          Or just ship them with the application. Life gets a lot easier when library versions are hard-wired at build time.

                          1. 9

                            shrug I don’t consider 100 MB to be large, the Slack desktop application is 490 MB and it uses 10x that in RAM to render a long scrollback

                            I believe the total on macOS for the bundle of shared libraries that are linked into every GUI process is now over 1 GiB. Slack is a bloated monster and a lot of Electron apps are moving towards things that use the platform’s native web view specifically so that the code can be shared across applications and reduce their RAM footprint.

                            The symbol versioning is part of the problem with GNU libc. I can’t compile a dynamically-linked C binary on Ubuntu 24.04 and expect it to run on Ubuntu 16.04, because it’ll try to resolve libc.so symbols that don’t exist in the older version.

                            No, of course you can’t. Almost nothing promises forward compatibility. You can compile on Ubuntu 16.04 and run with the glibc on 24.04 though.

                            This problem doesn’t exist when statically linking with musl.

                            Really? If you configure musl with a new Linux kernel and then run it on an old one, it will not give errors due to missing system calls?

                            The most popular Linux distribution in the world, Android, requires packaged applications to bundle their dependencies.

                            This is incredibly misleading. An Android app is four worlds:

                            • Platform-provided native libraries.
                            • Platform-provided ART (Java) libraries.
                            • User-provided ART code.
                            • User-provided native libraries (this one is optional).

                            The overwhelming majority of code in most applications is in the first two categories. A lot of apps are <1 MiB in the APK (which includes everything in the last two categories) but pull in orders of magnitude more in the first two. Even big apps are typically under 25% per-app code.

                            Or just ship them with the application. Life gets a lot easier when library versions are hard-wired at build time.

                            Depending on what you include in ‘life’. When there’s a security vulnerability in libxml2, for example, do you want to have to patch it once in the system or once in every single application? How do you find all of the vulnerable versions that each app has vendored?

                            And that doesn’t address the fact that you’re going to have 1-2 GiB of RAM consumed by multiple copies of Qt libraries if every single KDE app has its own version.

                            1. 7

                              No, of course you can’t. Almost nothing promises forward compatibility. You can compile on Ubuntu 16.04 and run with the glibc on 24.04 though.

                              On other platforms there’s some notion of a target API version – macOS has -macos_version_min, Android has minSdkVersion, Windows couples the minimum runtime target to the compiler version rather than the host OS.

                              GNU libc is unique among major platforms for not providing a way for the application’s build to specify a minimum version. There’s no preprocessor macros to control things like the fcntl -> fcntl64 transition, so binaries intended to dynamically link GNU libc.so must be built with a special sysroot.

                              Really? If you configure musl with a new Linux kernel and then run it on an old one, it will not give errors due to missing system calls?

                              Why would it? If the older version of the kernel is supported by libc (via fallback on -ENOSYS), there’s no reason why it should fail to run. And as far as I know, musl doesn’t have the concept of a build-time configuration for the Linux kernel version.

                              The overwhelming majority of code in most applications is in the first two categories. A lot of apps are <1 MiB in the APK (which includes everything in the last two categories) but pull in orders of magnitude more in the first two. Even big apps are typically under 25% per-app code.

                              I did a quick look at the applications on my phone. Firefox is 282 MB, Gboard (keyboard) is 173 MB, Kindle is 215 MB. The sizes quickly get larger if the applications contain any sort of multimedia, with games being several gigs (Genshin Impact alone is 22 GB according to the applications list).

                              For comparison, on my desktop, libgtk-4.so.1 is 8 MB.

                              Code size is not an important part of the installed size of modern application software.

                              Depending on what you include in ‘life’. When there’s a security vulnerability in libxml2, for example, do you want to have to patch it once in the system or once in every single application? How do you find all of the vulnerable versions that each app has vendored?

                              I want to patch it in every single application, so that I can control when application software behavior changes, so that I can avoid dealing with the situation where apt install rhythmbox pulls in a libxml2 update and now xsane crashes.

                              This is the way that Android, iOS, and the macOS package managers work – if some game on my phone was built against a vulnerable libxml2 then maybe the developers of that game will fix it and push an update, maybe they won’t.

                        2. 3

                          I constantly link against pre-built code, such as my system’s libstdc++ or libc++. That’s open source, but they’re dynamic libraries on the user’s machine (whcih might even be my machine if I’m the user), and ABI breaks in libc++/libstdc++ would break their build until I make a new build against the new stdlib.

                        3. 5

                          You can ship binaries of C + C++ + Rust without depending on C++ ABI stability by treating libc++ the way the Rust standard library is treated: by including it statically in the binaries you ship and relying only on the C ABI (including COM) on the boundary between system libraries and the binary you ship.

                          1. 7

                            That only works to some extent. For instance I tried to link against a static libc++ on macOS for my apps but that failed at runtime because macOS’s OS frameworks also have parts implemented in c++, causing symbol collisions and ultimately crashes (for instance iirc something in ImageIO.framework would call some std:: thing that uses some global state with ABI version N, while the symbol ultimately exposed to the whole process built with a more recent clang / libc++ would have ABI n+1 - and I have to expose said symbols due to my app itself having a c++ runtime plugin API thus plugins need to be able to see the libc++ symbols)

                            1. 6

                              Statically linking libc++ requires some mechanism to rewrite the symbols not to collide with the system C++ standard library.

                        4. 1

                          I hadn’t realized Carbon was specifically created out of frustration with the C++ process

                          Some disambiguation vis a vis the Carbon macOS API would be useful here. That was famous, in my world; this Carbon I have never heard of before in any context.

                        5. 8

                          Hi lobste.rs! This is my post. I hope you enjoyed it! <3

                          Someone approached me asking me if I wanted to join lobste.rs a few hours ago, so I guess that’s nice!

                          1. 4

                            It’s almost like continually adding new world-changing features without updating or removing old ones isn’t sustainable.

                            “In the long run, every program becomes rococco – then rubble.”

                            1. 4

                              The thing is, all these people who will never change their code unless forced are almost all using pinned tooling anyway. Who cares if they won’t use a better version?

                              1. 3

                                I hadn’t realized that ABI stability was a goal of C++ until there was a showdown over it. I’d always known people to use pure C ABIs between C++ binaries. I remember KDE folks padding their classes so that they could add members in micro-versions, but I don’t think they attempted compatibility across major versions.

                                1. 14

                                  There are three kinds of ABI here, and it’s worth unpicking them. Especially since none of them are actually part of the C++ standard.

                                  First, there’s the lowering C++ language constructs to machine code and data layouts. This is the C++ ABI for a given platform. It defines things like how vtables and RTTI are organised, how exception handling works, and so on. There are basically two that anyone cares about: the Itanium one that almost everyone uses (with a few minor tweaks, such as the 32-bit Arm or Fuchsia variants) and the Visual Studio one that Visual Studio uses. The Itanium C++ ABI has been stable (with additions for new features) for over 20 years. Visual Studio reserves the right to change their ABI every release of Visual Studio, but in practice they don’t very often.

                                  This ABI allows you to compile two C++ source files with different compilers and link them together. You can mix clang and gcc (or, these days, clang and cl.exe) in individual files and link the result together. This used to churn a lot in all C++ compilers, which was one of the reasons Linus didn’t like C++, but everywhere except Visual Studio is stable now.

                                  Next, there’s the C++ standard library ABI. In libc++, for example, a bunch of things are hidden behind macros to avoid breaking the ABI. The C++ standard tries quite hard to avoid requiring these (sometimes it fails), they’re mostly for places where people figured out a more efficient (but not binary compatible) implementation at some point and want to allow people who don’t care to opt in. If a standard-library function changed in such a way that its name mangling changed, for example, or if a library function that’s inline required a modification that would cause an ODR violation, that would break the standard-library ABI.

                                  As I recall, libstdc++ changed its ABI a few times in the early 2000s and then once for C++11. libc++ doesn’t support any pre-C++11 standards, so hasn’t had to yet. If you link something against the standard library, you want to have very strong backwards compatibility guarantees because everything links the standard library and so if two libraries (or a library and a program) expect different standard library ABIs then they can’t coexist in the same process.

                                  Finally, there’s the ABI for individual C++ libraries. As with C, it’s entirely up to the library vendor how strong they make their ABI-compatibility guarantees. You can expose type-erased interfaces from a shared library and provide versioned inline wrappers in headers that do the type erasure. If you do this, you can build long-term stable ABIs. Alternatively, you can put all class definitions in headers and end up with something that breaks ABIs every minor release. Both C and C++ codebases have done the thing where they add padding fields to allow future expansion without breaking ABIs. In C++, you need to make sure that you have out-of-line constructors (including copy / move constructors) so that replacing these with a type that needs copying / initialising differently will work. You probably need out-of-line comparison operators as well. You can often avoid this if you have types that don’t need subclassing or stack allocation by making constructors private and providing factory methods that return shared / unique pointers.

                                  The C++ standards committee is primarily concerned with the first two. New things in the standard shouldn’t require backwards-incompatible changes to either of these (new features requiring new bits of ABI are fine).

                                  1. 4

                                    but everywhere except Visual Studio is stable now.

                                    fwiw, MSVC 2015 and up at least promise binary compatibility, 2013 and earlier did not: https://learn.microsoft.com/en-us/cpp/porting/binary-compat-2015-2017?view=msvc-170

                                    1. 3

                                      I assume name mangling is in the C++ ABI.

                                      1. 5

                                        yup, here’s the Itanium spec

                                        1. 2

                                          Yes, it’s quite a large part of the Itanium ABI spec.

                                        2. 2

                                          Minor amendment about C++ ABI:

                                          While https://libcxx.llvm.org/ mentions “targeting C++11 and above”, C++03 is still supported.

                                          It’s a maintenance headache and a maintainer philnik recently has a patch to freeze C++03 headers in a subdirectory. https://discourse.llvm.org/t/rfc-freezing-c-03-headers-in-libc/77319

                                          I have some notes about libc++’s ABI compatibility and its implementation strategy https://maskray.me/blog/2023-06-25-c++-standard-library-abi-compatibility

                                          libstdc++ has https://gcc.gnu.org/onlinedocs/libstdc++/manual/using_dual_abi.html