Does undefined behaviour retroactively mean that earlier visible side-effects aren't guaranteed?
Asked Answered
W

2

16

In C++, a compiler can assume that no UB will happen, affecting behaviour (even visible side-effects like I/O) in paths of execution that will encounter UB but haven't yet, if I understand the phrasing correctly.

Does C have any requirement to execute a program "correctly" up to the last visible side-effect before the abstract machine encounters UB? Compilers seem to behave this way, but do so in C++ mode as well as C, so it could just be a missed optimization or an intentional choice to be less "programmer-hostile".

Would such an optimization be allowed by the ISO C standard? (Compilers might still reasonably choose not to do so for various reasons including difficulty of implementation without mis-compiling any other cases, or "quality of implementation" factors.)


The ISO C++ standard is fairly explicit about this point

This question is (primarily) about C, but C++ is at least an interesting point of comparison because the concept of UB is at least similar in both languages. I don't see any similarly explicit language in ISO C, hence this question.

ISO C++ [intro.abstract]/5 says this (and has since at least C++11, probably earlier):

A conforming implementation executing a well-formed program shall produce the same observable behavior as one of the possible executions of the corresponding instance of the abstract machine with the same program and the same input. However, if any such execution contains an undefined operation, this document places no requirement on the implementation executing that program with that input (not even with regard to operations preceding the first undefined operation).

I think the intended meaning of places no requirement on the implementation executing that program with that input is that even visible side-effects sequenced before the abstract machine encounters UB (such as a volatile access, or I/O including an unbuffered fprintf(stderr, ...)) aren't required to happen.

The phrasing "executing that program with that input" is talking about whole program, right from the start of its execution. (Some people talk about "time travel", but it's really a matter of things like later code allowing value-range assumptions (such as non-null) that affect earlier branching in compile-time decision making, as others have put it in a previous SO question. Compilers are allowed to assume that an execution of the whole program won't encounter UB.)

Test cases for real compiler behaviour

I tried to get a compiler to do the optimization I was wondering about. That would pretty definitively indicate it was allowed according the the compiler developers' interpretation of the standard. (Unless it was actually a compiler bug.) But everything I've tried so far has shown compilers preserving visible side-effects.

I've only tried with volatile accesses (not putchar or std::cout<< or whatever), on the assumption that it should be easier for the optimizer to see around and understand. Calls to non-inline functions like printf are generally black-boxes for optimizers, unless they're special-cased based on function name like for some very important functions such as memcpy. Also, a call to an I/O function could hypothetically block forever or even possibly abort, and thus never encounter the UB in later code.

Actually I've only tried with volatile stores, not volatile reads. Compilers might handle that differently some some reason, although you'd hope not.

Compilers do assume that volatile accesses don't trap, e.g. they do dead-store elimination around them (Godbolt). So a volatile load or store shouldn't stop the optimizer from seeing that UB in this path of execution will happen. (Update: this may not have proved as much as I thought, since if it did trap to a signal handler inside this program, ISO C and C++ both say that only volatile sig_atomic_t variables will have their "expected" values in a signal handler. So dead-store elimination of a non-volatile global across something that might raise a signal and then resume or not would still be allowed. But it still shows that volatile accesses are assumed not to be too weird.)

Some previous examples (such as Undefined behavior causing time travel) revolve around if/else examples where UB would be encountered in one side so compilers can assume the other side is taken.

But those have no visible side effects in the path of execution that definitely does lead to UB, only in the other path. This example does have that:

volatile int sink;     // same code-gen with plain   int sink;
void foo(int *p) {
    if (p)         // null pointer check *could* be deleted due to unconditional deref later.
        sink = 1;      // but GCC / clang / MSVC don't

    *p = 2;
}

GCC13 and clang16 compile it the same way for x86-64 (with -O3). (Godbolt: I'm compiling with -xc++ to tell them to treat it as C++.) Also MSVC19.37 but with the p arg in RCX instead of RDI.

foo(int*):
        test    rdi, rdi
        je      .LBB0_2                      #  if (!p)  goto .LBB0_2, skipping the if body
        mov     dword ptr [rip + sink], 1    # then fall-through, rejoining the other path
.LBB0_2:
        mov     dword ptr [rdi], 2
        ret

Using if(!p) as the loop condition, MSVC's code gen is the same except for jne instead of je. GCC and Clang do tail-duplication, making two blocks that each end with a ret, the first being just *p=2; and the second doing both stores. (Which is interesting since clang compiles *(int*)0 to zero instructions, but with tail-duplication it creates a block where it's proved p is null but still emits an actual store instruction.)

If we put *p = 2; before the if(), the null pointer check will indeed be deleted. (baz() in the Godbolt link: compiles to 2 unconditional stores.)

The fact that the "expected" optimization doesn't happen even with non-volatile (with -xc++ or -xc) could be a sign that compilers try to avoid retroactive effects in general as a way to avoid changing visible side-effects before UB is reached. Or it could just tell us that compilers aren't aggressive enough to demo my point. Inventing stores in non-UB cases is a tricky thread-safety violation, so I could imagine compilers being cautious about it.

One example of some success, at least for a non-volatile store, is:

volatile int sink;
void bar_nv(int *p) {
    /*volatile*/ int sink2;
    if (p) {
        sink = 3;    // volatile
    }else{
        sink2 = 4;   // non-volatile
        *p = 4;  // reachable only with p == NULL, so compilers can assume it's *not* reached.  Only clang takes advantage
    }
}

Clang16 -O3, compiling as either C or C++. (Unlike GCC which still branches).

bar_nv(int*):
        mov     dword ptr [rip + sink], 3
        ret

This optimizes away the entire branch containing the sink2 non-volatile side-effect.

If we make sink2 also volatile, then it branches and still does the visible side-effect of storing to sink2 in that path of execution before falling off the end of the function (not actually dereferencing p which is known to be null in that side of the if). See bar_v in the Godbolt link.

Another case I was playing around with: https://godbolt.org/z/vjqeb59TG puts *p derefs in both side of an if/else, leading to similar results to bar_nv vs. bar_v.

So I wasn't able to get compilers to optimize away a volatile side-effect from a path of execution that definitely leads to UB even in C++. But that doesn't prove the ISO C++ standard doesn't allow it. (I'm still somewhat curious if this is intentional, or if there is a case where such optimization happens.)

Doing a visible side-effect without actually faulting on a null-deref is different: null-deref is UB so nothing is guaranteed, not even actually faulting. It's UB so anything can happen, including doing nothing or doing random I/O.


Earlier Q&As (mostly I found C++ questions, not C):

  • This question was motivated by discussion in comments on a recent Q&A with @user541686, who claimed that even the C++ wording doesn't permit a compiler to ignore visible side-effects (especially printf or volatile accesses) before an undefined operation is reached. In later discussion, they may have narrowed their argument to a claim that such optimization is impossible because I/O might fault or block forever, thus not actually reaching the undefined operation. But I was able to show that GCC and clang do assume that volatile operations won't fault, or at least that they won't trap to other code within this program that could observe the state of other global variables.

    So I think they're wrong about C++, but find it plausible that ISO C could at least be interpreted to require all visible side-effects before the undefined operation to actually happen. (Which is what compilers actually do for C and C++.) But is that common, or is it normally interpreted to not require that?

  • Undefined behavior causing time travel - , asking about Raymond Chen's article Undefined behavior can result in time travel. That example doesn't have any visible side-effects before the UB in the path of execution which encounters UB and thus is assumed not to be reached by the earlier branch. Answers on that question describe the compiler being allowed to assume that UB is not reachable, but in that context it's not discussing omitting a visible side-effect that would have happened before the undefined operation.

  • C++ What is the earliest undefined behavior can manifest itself? - , most answers agree that the whole execution of the program is undefined, not just after UB is reached.

  • Are there any barriers that a time travelling undefined behavior may not cross? - A version of this question, with a similar litmus test. Answered only in comments, but opinions are that the visible side-effect is not guaranteed to happen.

  • If a part of the program exhibits undefined behavior, would it affect the remainder of the program? - haccks's answer quotes the C standard (n1570-3.4.3 (P2)) about the consequences of UB, and then asserts without justification that it applies to the whole program. That's not obvious from that wording in the C standard, and IDK if there's anything else relevant. Bathsheba's answer says "Paradoxically, the behaviour of statements that have ran prior to that are undefined too." but doesn't specify if that's talking about C or C++ or both, and doesn't cite any standardese to support it.

  • Does an expression with undefined behaviour that is never actually executed make a program erroneous? question, but @supercat posted a answer saying

    A C compiler is allowed to do anything it likes as soon as a program enters a state via which there is no defined sequence of events which would allow the program to avoid invoking Undefined Behavior at some point in the future

    They don't support that with a citation from the standard, but they commented on another question:

    Don't use the term "once Undefined Behavior occurs", but rather "Once conditions have been established which would make Undefined Behavior inevitable". Language in the C Standard which may have been intended to make Undefined Behavior be unsequenced relative to other code has instead been interpreted by some compiler writers to imply that it should be bound by laws of neither time nor causality.

    So it sounds like C is a lot less explicit that C++ about a retroactive lack of requirements on executions that will encounter UB. Which language specifically in the ISO C standard, and what's the argument for this interpretation of it, assuming that's actually what compiler writers think but still choose not to make their compilers optimize away visible side-effects along paths that are already headed for UB.

    (@supercat is notable for opinions that modern C and C++ aggressive optimization based on the assumption of no UB has missed the intent of the original authors of the standard. Especially when that includes things like signed integer overflow or comparing unrelated pointers which aren't a problem in asm on the machines we're compiling for. It's certainly not great, but promoting int variables in loops to pointer width is a fairly important optimization for 64-bit machines so there was obvious justification to start down this road which left modern C and C++ full of land-mines for programmers.)

In this question, I'm asking what the ISO C standard as written allows, either explicitly or per any commonly agreed-on interpretations. Especially whether that's even more permissive than what compilers actually did in my test cases. I'm not arguing whether or not real compilers should optimize even more; it seems reasonable not to.

Wun answered 20/9, 2023 at 21:48 Comment(28)
"Does undefined behaviour retroactively mean that earlier visible side-effects aren't guaranteed?" - in my experience, Yes.Lustful
@RichardCritten: Yes, I linked that in my question. It cites the C++ standard. I'm asking about the C standard. (And how it differs from C++, but maybe I should untag C++ since I'm fairly confident that my interpretation of the C++ standard's language is correct and widely agreed on. Now edited.) It seems to be widely agreed that the answer is yes, matching C++ which makes sense, but the question is what justification is there for it in the ISO C standard's language itself? Did C++ just more clearly state what was already true about C in practice?Wun
How far back are you asking? If a compiler makes an optimization assuming defined behaviour, and that's not the case, you could end up with an entire program that does nothing, when it should have sone something.Craver
@JesperJuhl: Do you have a specific example of a compiler doing that? Examples like Raymond Chen's make very fuzzy "back in time" arguments about what compilers are actually doing when they choose which way a branch goes. (Or arguments about corrupting stdio buffers, which were visible side effects yet.) As my question shows, I tried to get GCC, Clang, or MSVC to remove a volatile access that was followed by a deref they could see was a null pointer, but none of them did it.Wun
@tadman: Yes, exactly. If a compiler can prove the only way for a program to exit is by reaching the end of main, and there's a division by 0 or null deref there, the C++ standard allows the entire program to compile to one illegal instruction, or to main(){return 42;} This is normally implausible to prove, e.g. real progs tend to call library functions that real compilers don't make such strong assumptions about. (Like that they definitely return.) But in theory ISO C++ allows that. The question is whether ISO C also allows that, and if so what wording in the standard allows that.Wun
@tadman: The examples I tested were cases where a compiler could plausibly do that optimization if it were so inclined.Wun
I think you're asking a lot here. Just because that's not the case today doesn't mean it will be in the future, as a more aggressive optimizer could do that without violating any of the defined behaviour requirements.Craver
@tadman: I tried to keep the question itself narrowed to mostly just whether it's allowed in ISO C, hence the [language-lawyer] tag. Mainstream opinion seems to be yes, but which phrasing justifies that? The part about real compiler behaviour is mostly there to show that the answer isn't blindingly obvious in practice, and to distinguish the example I'm showing from other examples. (I avoided actually asking the related things, like whether GCC or Clang devs intentionally don't do this optimization in C++ because they need to avoid it in C, and why they miss it even with non-volatile...)Wun
(But answers that want to address any of those fun related things would be welcome.)Wun
There is a relatively recent paper on this issue on the WG14 document log which according to a footnote contained in it is a result of a UB study group: open-std.org/jtc1/sc22/wg14/www/docs/n3128.pdfEnt
I was recently wondering about something similar: what if the write to sink itself changes the flow of control, such that the later UB is not reached? You mention it in terms of trapping; I was thinking of something like a memory-mapped hardware register that resets the machine and effectively starts the program over. I am not sure if they are fundamentally different.Ministerial
@NateEldredge: I think that is ruled out by the dead-store elimination test-case, or at least that compilers don't think they need to guarantee anything about the state of non-volatile globals if it happens, let alone private locals. e.g. I wondered about a case where writing a volatile object modified the machine state including the program counter, e.g. creating a loop between the two volatile writes in the function. (That's why the dead-store elim test-case has two volatile stores, but I didn't end up writing about that because it seemed too much of a tangent.)Wun
@NateEldredge: But yes, restarting the program is a more plausible behaviour for an MMIO access to a hardware reset, especially with -ffreestanding (although that option doesn't change anything for the test-case.) Still, restarting would mean existing non-volatile state is blown away, so dead-store elim around it would still be allowed. But visible side-effects would have to stay. So yeah, a compiler that wants to support reset via MMIO stores shouldn't optimize away volatile accesses in paths that would otherwise lead to UB. Good point.Wun
@user17732522: Thanks for that link! The example (reordering of a trapping operation and a volatile access) is exactly what I was looking for in terms of real-world examples of compilers doing such an optimization. GCC and MSVC (godbolt.org/z/986McGMdc) do the division unconditionally before testing whether to store to volatile int x, meaning that the store doesn't happen if division traps, even though it would in the abstract machine with the right _Bool arg. (Clang does the division after the conditional store.)Wun
The "concrete" vs. "abstract" interpretation of UB in that paper overall is indeed exactly what I was asking about, I think. They say "the concrete interpretation seems to be better aligned with the current wording", like I suspected for ISO C, despite popular ideas probably based on the C++ standard's wording. (concrete = any visible side-effects sequenced before UB have to happen.) I may write up a self-answer at some point, but if any else wants to, that'd be great.Wun
@PeterCordes Unfortunately I don't have a example I can share, it happened at a previous employer and I don't have access to the code anymore and don't recall the exact details. But, I do remember that the UB was a function that was supposed to return a bool but didn't on all paths and that testing the return value of that function happened after writing some output to a file to determine whether more output should be written. The compiler (gcc) decided to not only optimize away parts of the bool function, but to not generate any file output at all. Adding return true; Fixed everything.Lustful
@JesperJuhl: You mean execution fell off the end of a non-void function, and the I/O that didn't happen was in the caller, after the call? So you had already "used" the return value by assigning it to a bool variable, copying the retval. (Or if C++, falling off the end of a non-void function is itself UB, and compilers will omit the ret instruction in that case, letting execution fall off into whatever code is next in memory.) Unless you mean something different, those are both cases of weird behaviour after the abstract machine reaches UB. (Thanks for sharing the details.)Wun
@PeterCordes Control flow reached the end of a bool returning function without returning a value (where it should have returned true) and that caused code like "write_stuff_to_file(); if (bool_returning_function()) { write_more_stuff(); }" to not even write the initial stuff to the file. As if "write_stuff_to_file();" had not even been called - even though that should have written something to the file regardless of what "bool_returning_function()" returned.Lustful
@JesperJuhl: Oh, I see. Yes, that does sound like an example of what I'm talking about, with no UB happening until after the first I/O call. It wasn't buffered I/O was it? Since failing to flush an I/O buffer is plausible behaviour for UB. But if it didn't crash or corrupt the buffer, I would have expected output to happen eventually if you exit cleanly.Wun
@PeterCordes "It wasn't buffered I/O was it?" - I honestly don't remember - sorry. I just remember finding the bug and noticing that "This is clearly UB, so let's fix that" and once I did everything worked as intended and I moved on. The program didn't crash and we didn't notice any corrupted I/O.Lustful
@JesperJuhl: Fair enough, totally understandable. My actual question is more or less answered by open-std.org/jtc1/sc22/wg14/www/docs/n3128.pdf - compilers currently allow retroactive effects, but they don't affect visible side-effects in practice except around volatile accesses, because I/O in practice is done by opaque library functions that compilers can't assume will even return. Which is why I find your report interesting, since it contradicts expectations from n3128. Could have been a compiler bug, or making valid(?) assumptions about I/O functions not faulting.Wun
@PeterCordes This was a while ago, using some version of GCC 7.? (on RHEL 6 or 7 via devtoolset) and compiling the code base as C++17 - I wouldn't rule out a compiler bug - but agressive UB related optimization also doesn't seem out of the question.Lustful
I assume you mean in C/C++ \ threads. W/ threads, you can't have semantics that include UB, period.Dilantin
@curiousguy: Yes, I'm talking about C and C++ compilers compiling code that has to work correctly if linked into a multi-threaded program. That means they can't for example compile if (c) x = 1; into load/cmov/store like MSVC did until 19.36 for the example function in my question, because if c is false, another thread could be writing x without any UB happening.Wun
A simple question: a "visible side effect" is defined in C11, 5.1.2.4p19. Just to confirm: is this "visible side effect" you're talking about? In other words: do you refer to the standard term "visible side effect"? Extra: what is an invisible side-effect? Any examples?Tay
@pmor: Yes, I mean the standardese term. An invisible side-effect would be something like x++ on int x which is subject to the as-if rule, not guaranteed to ever actually happen in the asm.Wun
@PeterCordes OK. About n3128.pdf: why godbolt.org/z/rYh4xM4sW is "questionable backwards time-travel"? What exactly is questionable there?Tay
@pmor: I don't see any time-travel in Clang's build of that source; it does the division after calling printf and fflush, so the output is visible even when the division faults in the non-inlined version. (In clang's main, it doesn't do the division at all. In the GCC executor you linked, it is printing before SIGFPE, so whatever code different from clang is still executing that way.)Wun
M
2

As I do not have access to the actual standard, looking at the N2310 draft, the text states:

'undefined behavior

behavior, upon use of a nonportable or erroneous program construct or of erroneous data, for which this document imposes no requirements'

The no requirements pretty clearly means that the standard no longer says anything at all about what takes place, including what it says in parentheses in the C++ standard. So optimizations assuming no UB are perfectly acceptable.

There's a note that follows:

'Possible undefined behavior ranges from ignoring the situation completely with unpredictable results, to behaving during translation or program execution in a documented manner characteristic of the environment (with or without the issuance of a diagnostic message), to terminating a translation or execution (with the issuance of a diagnostic message).'

'[I]gnoring the situation completely with unpredictable results' more strongly implies the same thing. Notably the ignoring can happen already during translation (i.e. compilation), before the program is executed.

Mattie answered 20/3, 2024 at 19:27 Comment(0)
S
0

The Standard is designed to, among other things, allow implementations to arbitrarily interleave strings of processing steps which have no observable sequencing relationship with operations outside their own string. If an action which has no specified observable side effects follows some other action which does have side effects, a compiler might reorder the action which has no side effects ahead of the preceding actions. If attempting to perform that action triggers an unexpected side effect, such side effect may manifest itself ahead of the "preceding" other actions which had documented side effects.

Nothing in the Standard, however, would forbid an implementation from processing code in ways that are inconsistent not only with code execution sequence, but even with normal laws of causality. Given a sequence like:

unsigned test(unsigned x, unsigned mask)
{
  unsigned i=1;
  while((i & mask) != x)
    i*=3;
  if (x < someValue)
    doSomething(x);
  return i;
}

nothing within the loop would seem capable of affecting the value of x, or consequently affect the behavior of the following if statement, but if the clang or gcc compilers can show that the return value of the above function will never be used, and that someValue is larger than the largest possible value of mask, they may decide to eliminate both the code for the loop and the conditional check, changing the function so it unconditionally passes x to doSomething().

Strontian answered 29/10, 2023 at 20:32 Comment(13)
+1. Here is a simpler example: int fun(int a, int b) { int q; printf("a\n"); q = a / b; printf("b\n"); return q; }Roger
@chqrlie: Your example would illustrate the kind of interleaving many people would view as relatively desirable and non-astonishing. My point was to illustrate that clang and gcc aren't bound by ordinary laws of causal dependency, and may thus transform code in ways that throw the Principle of Least Astonishment out the window.Strontian
I was hoping an answer would point to some specific phrasing in the ISO C standard and explain how it justifies what you're describing in the first paragraph, of interpreting it the same way C++ explicitly waives any guarantee of earlier behaviour. But I think it's pretty close to making sense of the thought process behind that reading of ISO C, as described in open-std.org/jtc1/sc22/wg14/www/docs/n3128.pdf for current behaviour.Wun
If I'm understanding your code example correctly, in the case you're describing where x >= someValue gets passed to doSomething(x), infinite-loop UB would have already happened in the abstract machine. So this isn't a retroactive effect in the sense I was asking about. This nasty behaviour (of value-range inference from a removed loop) seems clearly allowed (even under the "concrete UB" proposal in n3128). Just like nasal demons or formatting your hard drive are also not forbidden by ISO C, but most people wouldn't want their compilers to make asm that did that in practice either.Wun
@PeterCordes: If the Standard were intended to treat endless loops as "anything can happen" UB in and of themselves, it could have specified that all loops without side effects shall terminate. It didn't do that. The authors wanted to allow compilers to make some optimizations that would not otherwise be legitimate, but hand-waved around the question of what those should be. If the goal is to allow programmers to write source code that compilers can use to satisfy application requirements as efficiently as possible, specifying that a loop or other chunk of code with a single exit point...Strontian
...need only be treated as sequenced before action which follows after the exit if some individual action other than a control transfer which occurs before the exit would be likewise sequenced, would achieve that far more effectively than characterizing such loops as UB, since it would allow programmers to know that no inputs to a program could cause it to perform some particular action without having to add dummy side effects *which would completely nullify any benefits the "optimizing" transforms might have been able to achieve.Strontian
I don't dispute that the current situation is not great, but the current ISO C standard does categorize this loop as UB. And compilers take that and run with it... like a pair of scissors. That's bad, but is a separate issue from what this question is about.Wun
@PeterCordes: The Standard makes no systematic effort to identify and forbid all of the things poor quality implementations could do to undermine their usefulness. Indeed, the published Rationale recognizes that one could construct an implementation which, despite being conforming, would still "succeed at being useless". Implementations which treat potentially-endless loops in clang/gcc fashion will be less suitable for most applications involving untrusted input than those which promise not to engage in such nonsense.Strontian
I know that, and we've discussed that before. This is still a separate case from inevitable UB after a visible side-effect preventing the visible side-effect from being reached in the first place, which is what this question is specifically about. The ISO C standard does not clearly and unambiguously give implementations the freedom to do that, although IIRC open-std.org/jtc1/sc22/wg14/www/docs/n3128.pdf did manage to cook up one examples of compilers doing it in practice, and apparently the majority opinion is that ISO C does currently allow it.Wun
@PeterCordes: Treating endless loops as inviting arbitrary side effects, as opposed to saying that loops which have no observable effects may be omitted without regard for whether they terminate [which is, according to the footnote, the purpose of the provision], will make the optimization opportunities afforded thereby useful only for erroneous programs, or those which would have no behavioral requirements for some inputs, and completely negate the intended optimizations for programs which would be required to uphold at least some behavioral requirements even for erroneous inputs.Strontian
This question isn't about infinite-loop UB and how current GCC/clang optimize it. If you want to write about that in detail, please post that part somewhere else, perhaps a self-answered Q&A. (Unless you have an example where the presence of a loop affects optimization of code before the loop.) It's an interesting topic, but a different one from this question which I intentionally kept narrow enough to be on-topic and answerable.Wun
@PeterCordes: Besides, I think there needs to be a point of clarity as to whether such polls are asking the question whether some implementations should be allowed to process some corner case in a manner that could arbitrarily corrupt memory, or whether all programmers should always be required to avoid that corner case at all costs. In many cases, the answer to #1 should be yes, but the answer to #2 should be no, but the authors of clang and gcc view "yes" answers to #1 as "yes" answer to #2.Strontian
@PeterCordes: Besides, my main point is that there is a general provision which allows actions which have recognized side effects to be reordered ahead of actions which do not have recognized side effects, and the only way to allow this under the as-if rule is to categorize as UB any actions which could have unrecognized side effects. The endless loop provision is merely an application of the general rule which is very much focused on allowing time travel.Strontian

© 2022 - 2025 — McMap. All rights reserved.