Hacker News new | past | comments | ask | show | jobs | submit login

> Because uninitialized variables are allowed to change values arbitrarily over their (nonexistent) "live range".

Even specifying that they have a new arbitrary value on each access would be a big improvement over the status quo. It wouldn't allow nasal demons. Since LLVM seems to already have these semantics, it makes a good argument that it wouldn't hurt C's performance to tighten the spec at least that much.

But I'm not seeing how C or C++ code gets you in a situation where you're purposefully doing arithmetic or bitwise operations on uninitialized variables. If it almost never happens, it doesn't need to optimize particularly well. What parts of the STL can be faster by treating uninitialized variables as impossible?

> There's also the issue that having a stable value forces the register allocator to keep a live range for the undefined variable, which can cause unnecessary spills or remat in other places.

This would only happen when the variable is accessed multiple times. In which case having to keep the value safe is no worse than if it actually had been initialized. I don't see how this is a problem.




> What parts of the STL can be faster by treating uninitialized variables as impossible?

What I'm mostly thinking of is allowing unused branches to be pruned. The STL tends to get inlined really heavily, which results in a whole pile of IR being emitted for what look like very simple operations. Based on the actual parameters and state, the optimizer then wants to prune out as much dead code as possible to reduce i-cache footprint, and sometimes time as well.

Take small string optimization. That optimization requires a branch on length in almost every string operation. But if you have a std::string of constant size, you don't need the heap spilled code to be emitted at all. Usually the only way to work this out is inlining + constprop + DCE. That's where undefined value semantics are really helpful: a branch on an undefined value can be completely removed as undefined behavior, which can make its branch targets unreachable, and allow them to be removed, and so on recursively. Undefined values allow entire CFG subtrees to be eliminated in one fell swoop, which is an extremely powerful technique for reducing code size.

I don't have a precise example off the top of my head as to where this kicks in in the STL, but I strongly suspect it does.


I still don't understand. Why would length be undefined if that's how you tell whether a string is small or not?

Even if you can remove one of the branches because you know if the string is small, the logic of "this branch can't happen" -> undefined -> delete sounds more complex than "this branch can't happen" -> delete.


The order is undefined -> "I, the compiler, declare this branch can't happen" -> delete. The middle step is valid because "undefined behavior" permits the compiler to make that declaration, then act on it. If you don't want it to do that, use defined behaviors only, which is a great deal easier said than done. Partially because of how hard it is to avoid it in your own code, and partially because it is shot through all the other library code (which as pcwalton points out, courtesy of aggressive inlining, is also your code).

I was skeptical about all this about six months ago myself, but the continuous stream of articles on this topic, plus the spectacular and highly educational failure of Friendly C (and not just that it failed, but why it failed, which is why I posted that exact link) has satisfied me. It is also part of why I've stepped up my own anti-C rhetoric since I've been so convinced... as bad as I thought C was, it really is, no sarcasm, not merely "hating", even worse than I thought. I am honestly scared to use it professionally and pretty much refuse to touch it without good static analysis support.


I just don't understand how "std::string of constant size" leads to the compiler inferring that a variable is undefined along a particular code path. In particular, on the not-taken code path, what is the uninitialized variable being branched on?

Edit: Is it figuring out that the pointer in the string object is uninitialized? That doesn't seem any easier than reasoning about the length value, and I don't see how it would lead to "br undef".


LLVM has been doing this optimization since 2008, and it contains justification in the commit message. LLVM commit #60470:

    Teach jump threading some more simple tricks:
    
    1) have it fold "br undef", which does occur with
       surprising frequency as jump threading iterates.
    ...
Chris didn't cite any specific numbers in the log, but I believe him when he says it actually happens. You should be able to run "opt -O2 -debug-only=jump-threading" and see where it does :)

Related, with some actual numbers to prove it helps, LLVM commit #138618:

    SimplifyCFG: If we have a PHI node that can evaluate to NULL and do a load or
    store to the address returned by the PHI node then we can consider this
    incoming value as dead and remove the edge pointing there, unless there are
    instructions that can affect control flow executed in between.
        
    In theory this could be extended to other instructions, eg. division by zero,
    but it's likely that it will "miscompile" some code because people depend on
    div by zero not trapping. NULL pointer dereference usually leads to a crash so
    we should be on the safe side.
        
    This shrinks the size of a Release clang by 16k on x86_64.


For the price of removing some of the rusty nails sticking out of C, I'll happily pay a few 16KB on a binary as large as clang. Plus in most or all of those cases, the optimization would still be possible even with stable uninitialized values. It just needs to be done in a different way. You might be able to get 13 of those 16KB back with a very minor amount of work.


> For the price of removing some of the rusty nails sticking out of C, I'll happily pay a few 16KB on a binary as large as clang.

Lots of LLVM users won't. The competition between LLVM and GCC is (or was) pretty brutal.

> Plus in most or all of those cases, the optimization would still be possible even with stable uninitialized values. It just needs to be done in a different way.

I doubt that's possible without justification. How? Are you familiar with all of the passes of LLVM, how they interact, and with the code patterns generated by the STL?

I trust Chris in that he didn't add the jump threading optimization for no reason, which is the main one that matters here. If he says that this occurs with "surprising frequency", sorry, but I'm going to trust him. Compiler developers are usually right about the impact of their optimizations. Submit a patch to LLVM if you like to remove it, but I highly doubt it'll go through. If it did go through, Rust (and perhaps Swift) would probably revert it, as we ensure that we don't emit UB in the front end, so losing the optimization hurts us for no reason.


The goal is to refine the semantics of C. Removing a valid optimization doesn't help that.

It's not that I think the Chris is wrong, it's that I think other optimizations related to dead code have gotten better in the last many years, and focused effort could improve them more.


I think "refining the semantics of C" is a doomed effort that we shouldn't undertake. We'll lose performance for very little benefit, if market game theory even made it possible (and it doesn't: benchmarks of C compiler performance are much more important to customers than friendliness of the C dialect). We should just be moving away from C instead, or if we must stick with C we should invest in dynamic checks for undefined behavior.

My position, by the way, is hardly uncommon among longtime C compiler developers.


Do you think C managed to get things exactly right? Or should we add more kinds of undefined behavior?

What do we do about the fact that undefined behavior actually makes it harder or impossible to write efficient code in some cases, like checking for integer overflow?

Even if your goal is performance over anything, there are a whole lot of undefined behaviors that have absolutely zero performance benefit.


> What do we do about the fact that undefined behavior actually makes it harder or impossible to write efficient code in some cases, like checking for integer overflow?

This is a good example of why we should be moving away from C. :) Signed overflow being undefined is basically necessary due to a self-inflicted wound from 1978: the fact that "int" is the easiest thing to type for the loop index when iterating over arrays. Nobody is going to go through and fix all the C code in existence to use unsigned, so we're stuck with that UB forever. The realistic solution is to start migrating away from C (and I have no illusions about how long that will take, but we'll never get there if we don't start now).

> Even if your goal is performance over anything, there are a whole lot of undefined behaviors that have absolutely zero performance benefit.

Sure. But compilers don't exploit those in practice (because compiler authors are not language lawyers for fun), so they're basically harmless in practice. They're essentially just spec bugs for the committee to fix in the next revision.


It just seems like such a waste to abandon C instead of smacking compiler/spec writers and getting them to specify behavior that was de-facto specified for decades.

C should be writable by humans.


Totally disagree, and it's disappointing to see you suggest "smacking compiler writers" after this discussion which clearly established why the change you suggested does not work. This is not the fault of compiler writers like Chris, at all. They understand how the entire set of optimization passes fits together and do not throw in optimizations for no reason. They are doing what needs to be done to get performance out of a language written in 1978 with design mistakes. They are doing this because their customers demand it.

This entire discussion is predicated on the idea that undef values having a live range is something that actually prevents bugs, which I find extremely dubious. This is not like the "null dereference is UB" thing in the Linux kernel (which I also found to be overstated in importance, but it has caused at least one problem). I can't think of a single bug that this has caused, other than a compiler bug in Rust that was only exposed by people intentionally trying to write code that would break--that is, the bug had no practical effect. It strikes me as "friendly C lawyering" for little benefit.


>They are doing what needs to be done to get performance out of a language written in 1978 with design mistakes.

Then smack the spec writers for never removing undefined behavior. You don't have to smack both, but the two groups are working together to make C more dangerous every day.

>They are doing this because their customers demand it.

If you show a chart of how often the customer's code will stop working because of these optimizations, perhaps they will stop demanding it. But even if it's implemented, there should be a --reduce-undefined-optimization flag.

> I can't think of a single bug that this has caused

If it was defined, then the Debian RNG bug likely would not have happened.

If an undefined behavior is rare, that's a good reason to flatly remove it from the spec. If we could have 10 undefined behaviors instead of 200, the world would be better off. There are undefined behaviors you can hit during preprocessing, for crying out loud.

(Also even in the most extreme version, you wouldn't need to count it as live until the first access, and if that's the last access then there's no live range at all.)


> If it was defined, then the Debian RNG bug likely would not have happened.

No. The Debian bug was not the result of a compiler optimization based on undefined behavior. It was due to OpenSSL doing something dodgy [1]. The compilers, being conservative, all declined to do any optimizations based on the undefined behavior. It was Valgrind that called OpenSSL out for the uninitialized use of memory. This is not a bug in Valgrind either! If the C standard defined live ranges for uninitialized values, then Valgrind would still be right to flag uses of uninitialized values, because they usually indicate bugs, and Valgrind is a bug finding tool. The Debian bug occurred because OpenSSL was doing something silly, Valgrind was doing its job properly pointing out that silly thing that OpenSSL was doing, and the fix was dangerously incorrect.

[1]: http://research.swtch.com/openssl


They got a report of dodgy code. Looking at the code, it was intentional but unimportant and also violated the C standard. So they removed it, and they removed it badly.

If it had been valid by the C standard, it is not unlikely that they would have kept it. Then there would have been no disaster. They would not have gotten confused about what was valid and what wasn't.

Extra entropy to mix in isn't that silly.

Edit: In other words, making "Is this code valid?" more complex to answer can have bad effects even in the absence of aggressive compilers.


No, this is again completely incorrect. This had nothing whatsoever to do with the C standard. This is Valgrind's issue. Valgrind does not care about what the C standard says. Valgrind is a bug-finding tool and as such can make its own rules.

Once again, if the C standard were to change to allow uninitialized variables to have live ranges, Valgrind would not just "git rm -rf memcheck; git commit". That is because Valgrind is a tool that is designed to check for uninitialized memory. It did its job properly. It is not designed to check for undefined behavior according to the C standard.

You seem to be determined to lay the blame for every problem at some combination of C compiler authors and C spec writers, regardless of whether the problem actually is theirs.


I think it's likely they would have kept the code that valgrind didn't like if it was not a violation of the C standard. I don't understand why you completely reject that as a possibility.

I don't blame all problems on C, but I think it should be easier to write valid C. Dealing with uninitialized variables was just an example of something that could be changed. It wasn't supposed to be the biggest most horrible problem with C.

Compilers may not turn functions that hit most kinds of UB into { return; }, but they can. As a general category, that's a bad thing.


> I think it's likely they would have kept the code that valgrind didn't like if it was not a violation of the C standard. I don't understand why you completely reject that as a possibility.

It's highly unlikely that the maintainer of OpenSSL at Debian is a spec lawyer. The maintainer ran Valgrind because, as a general principle, reading undefined memory is bad. That is independent of whether the C standard says that it's OK. If I were the maintainer of the package in question, I would not ignore reads of undefined memory just because the C committee has declared that it has some kind of behavior. That would be silly.

Valgrind contains many tools that check for things that are not violations of the C standard: for example, memcheck checks for "fishy" arguments to malloc that are not C standard violations but are nevertheless usually bugs.

> Compilers may not turn functions that hit most kinds of UB into { return; }, but they can. As a general category, that's a bad thing.

Depends on the undefined behavior. For undefined behavior coming from the C preprocessor, sure, that shouldn't be undefined, and the committee should change the spec. For other kinds of UB, if compiler authors hadn't figured out those tricks, we'd have slower programs today. Undefined behavior is much of what has allowed performance of our programs to keep improving despite the language not changing over decades.


> Even specifying that they have a new arbitrary value on each access would be a big improvement over the status quo.

This is approximately what LLVM describes undef as, and it indeed leads to nasal demons. This description enables a single value to both pass a bounds check, and then subsequently go out of bounds!


You're right, in certain cases it would still cause trouble, but it would be a lot fewer cases than 100%. Reading only once would be safe, and passing it to another functions would make a variable that can no longer change unexpectedly.


Unless the function is inlined/the compiler can deduce that the variable is undef.

(Of course, you could define it to work, but I would suspect that would make this sort of value much less useful.)




Guidelines | FAQ | Lists | API | Security | Legal | Apply to YC | Contact

Search: