From 9e0b58c63d3e1c5441750f89bd1bf0fabfdd79b9 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Sun, 2 Aug 2026 16:14:23 -0700 Subject: [PATCH] Build with -O3 -fno-strict-overflow; drop -march=native MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Steelmanning -march=native turned up the opposite of what we assumed, plus a bigger problem underneath it. -march=native is slower. ~12% behind plain -O3 on Zen 4, worst on the numeric loops it should help most: -16% integer, -21% float. Two runs, 21 interleaved passes, best-of. Microbenchmarks are the friendliest case it gets, so there's no reason to expect a win on real work. Dropped. The bigger find: the other four definitions were building unoptimized. Leaving RUBY_CFLAGS empty doesn't mean "use configure's default" — ruby-build exports it as CFLAGS, which supersedes configure's optflags in the compile line, so an empty value is -O0. Measured 2.2-3.5x slower across method calls, integer math, string building, hash churn and array ops. rbconfig still reports optflags: -O3 in that state, which is why it went unnoticed; the timings are what to trust. Unintended fallout from b719789, which made RUBY_CFLAGS="" unconditional where it had previously only been set under clang. Turning optimization on activates two things -O0 was silently masking, and neither is caught by "does it compile and run": -fno-strict-overflow is mandatory, not caution. These sources assume signed overflow wraps; GCC treats that as UB and exploits it from -O2 up. 1.8.7 built at -O3 without it compiles clean, runs, loads every stdlib — and evaluates 2**64 to 0, typed Fixnum. Silent wrong arithmetic. Confirmed -O2 breaks it too, and that the flag costs nothing measurable (within noise on 2.7.8, two of five benchmarks nominally faster with it). 1.9.3 additionally caps _FORTIFY_SOURCE at 2 under GCC. Ubuntu's GCC raises it to 3 whenever optimizing, and level 3's object-size inference aborts the build with "*** buffer overflow detected ***" while running the freshly built miniruby. Not a hardening regression: fortify does nothing without optimization, so at -O0 there was none at all. Scoped to 1.9.3; the rest build fine at 3. So test/build now asserts overflow arithmetic. The existing checks would have passed a Ruby computing 2**64 == 0 — verified by building the unsafe variant and watching it clear ruby --version, openssl, digest and zlib before the new assertion caught it. Failures also dump ruby-build's own log now, and strip curl's progress meter: several definitions send compiler output to that log rather than stdout, and diagnosing the 1.9.3 abort meant reproducing it by hand because the container took the log with it. Full matrix green, 12/12 on Arch and Ubuntu Noble. --- 1.8.7-p374 | 10 +++++++++- 1.9.3-p551 | 20 +++++++++++++++++++- 2.3.3 | 11 ++++++++++- 2.3.8 | 11 ++++++++++- 2.5.9 | 10 +++++++++- 2.7.8 | 10 +++++++++- README.md | 26 ++++++++++++++++++++++++++ test/build | 29 +++++++++++++++++++++++++---- 8 files changed, 117 insertions(+), 10 deletions(-) diff --git a/1.8.7-p374 b/1.8.7-p374 index ba3d8d6..e333dba 100644 --- a/1.8.7-p374 +++ b/1.8.7-p374 @@ -38,7 +38,15 @@ install_bundler() { "$PREFIX_PATH"/bin/gem install bundler -v 1.17.3 } -RUBY_CFLAGS="" +# -O3 because leaving this empty does not mean "use configure's default". ruby-build +# exports it as CFLAGS, which supersedes configure's own optflags in the compile line, so +# an empty value builds at -O0 — measured 2-3x slower than the same source at -O3. +# +# -fno-strict-overflow is mandatory here, not belt-and-braces. 1.8.7's fixnum overflow +# checks assume signed overflow wraps, which is undefined behaviour that GCC exploits from +# -O2 upward: without this flag a -O3 build silently evaluates 2**64 to 0 and types it +# Fixnum. Wrong arithmetic, no warning, no crash. It costs nothing measurable. +RUBY_CFLAGS="-O3 -fno-strict-overflow" case "$(cc -v 2>&1)" in *gcc*) RUBY_CFLAGS+=" -Wno-discarded-qualifiers" diff --git a/1.9.3-p551 b/1.9.3-p551 index 6b884ec..fae5396 100644 --- a/1.9.3-p551 +++ b/1.9.3-p551 @@ -19,7 +19,15 @@ install_bundler() { "$PREFIX_PATH"/bin/gem install bundler -v 1.17.3 } -RUBY_CFLAGS="" +# -O3 because leaving this empty does not mean "use configure's default". ruby-build +# exports it as CFLAGS, which supersedes configure's own optflags, so an empty value builds +# at -O0 — measured 2-3x slower than the same source at -O3. +# +# -fno-strict-overflow guards the fixnum overflow checks in these old sources, which assume +# signed overflow wraps — undefined behaviour that GCC exploits from -O2 up. On 1.8.7 its +# absence silently makes 2**64 evaluate to 0. Kept uniform across every definition here; +# it costs nothing measurable. +RUBY_CFLAGS="-O3 -fno-strict-overflow" case "$(cc -v 2>&1)" in *gcc*) RUBY_CFLAGS+=" -Wno-discarded-qualifiers" @@ -28,6 +36,16 @@ case "$(cc -v 2>&1)" in RUBY_CFLAGS+=" -Wno-return-type" # GCC 15 porting: Use -std=gnu99 with relaxed type checking RUBY_CFLAGS+=" -std=gnu99 -Wno-error=implicit-function-declaration" + # Cap _FORTIFY_SOURCE at 2. Ubuntu's GCC defines it to 3 automatically whenever + # optimization is on, and level 3's more aggressive object-size inference trips on + # 1.9.3: the build aborts with "*** buffer overflow detected ***" while running the + # freshly built miniruby to generate exts.mk. Level 2 builds and runs clean. + # + # This is not a loss of hardening relative to before. _FORTIFY_SOURCE does nothing + # without optimization, so while this definition was (unintentionally) building at + # -O0, no fortification was happening at all. Only 1.9.3 needs the cap; the other + # definitions build fine at level 3. + RUBY_CFLAGS+=" -U_FORTIFY_SOURCE -D_FORTIFY_SOURCE=2" ;; *clang*) RUBY_CFLAGS+=" -Wno-compound-token-split-by-macro" diff --git a/2.3.3 b/2.3.3 index 87569aa..bdeb5bb 100644 --- a/2.3.3 +++ b/2.3.3 @@ -12,7 +12,16 @@ install_bundler() { "$PREFIX_PATH"/bin/gem install bundler:1.17.3 } -RUBY_CFLAGS="" +# -O3 because leaving this empty does not mean "use configure's default". ruby-build +# exports it as CFLAGS, which supersedes configure's own optflags, so an empty value builds +# at -O0 — measured 2-3x slower than the same source at -O3. (rbconfig still reports +# optflags: -O3 in that case, which is misleading; the timings are what to trust.) +# +# -fno-strict-overflow guards the fixnum overflow checks in these old sources, which assume +# signed overflow wraps — undefined behaviour that GCC exploits from -O2 up. On 1.8.7 its +# absence silently makes 2**64 evaluate to 0. Kept uniform across every definition here; +# it costs nothing measurable. +RUBY_CFLAGS="-O3 -fno-strict-overflow" case "$(cc -v 2>&1)" in *gcc*) RUBY_CFLAGS+=" -Wno-discarded-qualifiers" diff --git a/2.3.8 b/2.3.8 index ab9a9f5..a39620f 100644 --- a/2.3.8 +++ b/2.3.8 @@ -12,7 +12,16 @@ install_bundler() { "$PREFIX_PATH"/bin/gem install bundler:1.17.3 } -RUBY_CFLAGS="" +# -O3 because leaving this empty does not mean "use configure's default". ruby-build +# exports it as CFLAGS, which supersedes configure's own optflags, so an empty value builds +# at -O0 — measured 2.6-3.5x slower than the same source at -O3. (rbconfig still reports +# optflags: -O3 in that case, which is misleading; the timings are what to trust.) +# +# -fno-strict-overflow guards the fixnum overflow checks in these old sources, which assume +# signed overflow wraps — undefined behaviour that GCC exploits from -O2 up. On 1.8.7 its +# absence silently makes 2**64 evaluate to 0. Kept uniform across every definition here; +# it costs nothing measurable. +RUBY_CFLAGS="-O3 -fno-strict-overflow" case "$(cc -v 2>&1)" in *gcc*) RUBY_CFLAGS+=" -Wno-discarded-qualifiers" diff --git a/2.5.9 b/2.5.9 index 24e256c..0b5c673 100644 --- a/2.5.9 +++ b/2.5.9 @@ -3,7 +3,15 @@ install_bundler() { "$PREFIX_PATH"/bin/gem install bundler:2.3.27 } -RUBY_CFLAGS="-O3 -march=native" +# -march=native was here and has been dropped: measured ~12% *slower* than plain -O3 on +# Zen 4, worst on numeric loops (-16% integer, -21% float). Microbenchmarks are the +# friendliest case for it, so there's no reason to expect a win on real work either. +# +# -fno-strict-overflow guards fixnum overflow checks that assume signed overflow wraps — +# undefined behaviour GCC exploits from -O2 up. 2.5 uses builtin overflow intrinsics and +# doesn't strictly need it, but it's free (within noise here) and keeps every definition +# in this repo on the same flags. +RUBY_CFLAGS="-O3 -fno-strict-overflow" case "$(cc -v 2>&1)" in *gcc*) RUBY_CFLAGS+=" -Wno-discarded-qualifiers" diff --git a/2.7.8 b/2.7.8 index f30c7ab..82cf94a 100644 --- a/2.7.8 +++ b/2.7.8 @@ -3,7 +3,15 @@ install_bundler() { "$PREFIX_PATH"/bin/gem install bundler:2.4.22 } -RUBY_CFLAGS="-O3 -march=native" +# -march=native was here and has been dropped: measured ~12% *slower* than plain -O3 on +# Zen 4, worst on numeric loops (-16% integer, -21% float). Microbenchmarks are the +# friendliest case for it, so there's no reason to expect a win on real work either. +# +# -fno-strict-overflow guards fixnum overflow checks that assume signed overflow wraps — +# undefined behaviour GCC exploits from -O2 up. 2.7 uses builtin overflow intrinsics and +# doesn't strictly need it, but it's free (within noise here) and keeps every definition +# in this repo on the same flags. +RUBY_CFLAGS="-O3 -fno-strict-overflow" case "$(cc -v 2>&1)" in *gcc*) RUBY_CFLAGS+=" -Wno-discarded-qualifiers" diff --git a/README.md b/README.md index 4ba30e7..333bce0 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,32 @@ macOS the OpenSSL 1.0 builds come from [basecamp/homebrew-dev](https://github.co everywhere else OpenSSL is compiled from source into the Ruby's own prefix, so nothing lands system-wide and nothing is shared between versions. +### Optimization flags + +Every definition builds with `-O3 -fno-strict-overflow`. Both halves matter: + +**`-O3`** — leaving `RUBY_CFLAGS` empty is not "use configure's default". ruby-build exports +it as `CFLAGS`, which supersedes configure's own `optflags` in the compile line, so an empty +value builds at `-O0`. That's 2–3.5× slower. Confusingly, `RbConfig::CONFIG["optflags"]` +still reports `-O3` in that case; time a build rather than believing it. + +**`-fno-strict-overflow`** — these sources predate the compilers building them, and their +fixnum overflow checks assume signed overflow wraps. That's undefined behaviour, and GCC +exploits it from `-O2` up. Build 1.8.7 at `-O3` without this flag and it compiles cleanly, +runs, loads every stdlib — and evaluates `2**64` to `0`, typed `Fixnum`. Silent wrong +arithmetic. `test/build` asserts against exactly this, so the trap can't come back. + +`-march=native` is deliberately **not** used. It measured ~12% slower than plain `-O3` on +Zen 4, worst on numeric loops (−16% integer, −21% float), and microbenchmarks are the +friendliest case it gets. It would also make binaries non-portable between machines for no +gain. + +**1.9.3 caps `_FORTIFY_SOURCE` at 2 under GCC.** Ubuntu's GCC raises it to 3 automatically +whenever optimization is on, and level 3's object-size inference trips on 1.9.3 — the build +aborts with `*** buffer overflow detected ***`. This isn't a hardening regression: fortify +does nothing without optimization, so while these were building at `-O0` there was none at +all. Only 1.9.3 needs the cap. + ### Prerequisites **macOS:** Xcode command line tools and [Homebrew](https://brew.sh). diff --git a/test/build b/test/build index ec87675..79545b2 100755 --- a/test/build +++ b/test/build @@ -89,11 +89,19 @@ build_image() { } # Default post-flight checks - can be overridden via test/verify/ +# +# The arithmetic check is not paranoia. These sources predate the compilers building them +# and their fixnum overflow checks assume signed overflow wraps, which is undefined +# behaviour GCC exploits from -O2 up. A 1.8.7 built -O3 without -fno-strict-overflow +# compiles cleanly, runs, loads every library below — and evaluates 2**64 to 0. Nothing +# above this line would notice, so any change to optimization flags could ship silent wrong +# arithmetic. Kept 1.8.7-compatible: no interpolation-free heredocs, no modern syntax. default_verify_script() { cat <<'VERIFY' /opt/ruby/bin/ruby -e 'require "openssl"; puts "openssl: #{OpenSSL::OPENSSL_VERSION}"' /opt/ruby/bin/ruby -e 'require "digest/sha2"; puts "digest: ok"' /opt/ruby/bin/ruby -e 'require "zlib"; puts "zlib: ok"' +/opt/ruby/bin/ruby -e 'raise "2**64 wrong: #{2**64}" unless (2**64).to_s == "18446744073709551616"; raise "2**100 wrong" unless (2**100).to_s == "1267650600228229401496703205376"; raise "mul overflow wrong" unless (4611686018427387903 * 2).to_s == "9223372036854775806"; raise "negative overflow wrong" unless (-2**64).to_s == "-18446744073709551616"; raise "10**20 wrong" unless (10**20).to_s == "100000000000000000000"; puts "arithmetic: ok"' VERIFY } @@ -114,10 +122,19 @@ test_ruby() { verify_script=$(default_verify_script) fi - # Build Ruby and run post-flight checks + # Build Ruby and run post-flight checks. + # + # On failure, dump ruby-build's own log before exiting. Several definitions send the + # compiler output there (>&4) rather than to stdout, and the container is --rm, so + # without this the log dies with it and all you get is "BUILD FAILED" over a screen of + # curl progress bars. local build_script=" set -e - ruby-build $version /opt/ruby + ruby-build $version /opt/ruby || { + echo '--- ruby-build log (tail) ---' + tail -60 /tmp/ruby-build.*.log 2>/dev/null + exit 1 + } /opt/ruby/bin/ruby --version $verify_script " @@ -135,8 +152,12 @@ test_ruby() { printf ' %-14s %-14s ✗ %-62s %4ds\n' "$platform" "$version" "FAILED" "$elapsed" echo "fail" > "$RESULTS/$platform.$version.status" # Keep the log for the end-of-run report rather than interleaving it with - # other jobs' output as it happens. - echo "$output" | tail -30 > "$RESULTS/$platform.$version.log" + # other jobs' output as it happens. Strip curl's progress meter first — it's + # carriage-return spam that otherwise fills the tail and buries the actual error. + echo "$output" \ + | grep -vE '^ *[0-9 ]+ +[0-9]+ +[0-9]+[0-9k. ]*(--:--:--|[0-9]+:[0-9]+:[0-9]+)' \ + | grep -vE '^ *% Total|^ *Dload' \ + | tail -50 > "$RESULTS/$platform.$version.log" fi return 0 }