Skip to content

refactor: use cfg-aliases for feature flags #3865

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 3 commits into
base: master
Choose a base branch
from

Conversation

joshka
Copy link

@joshka joshka commented Mar 25, 2025

Introduces aliases for each feature flag as well as for the main
combinations of http version and client/server roles. This makes it
significantly easier to understand which feature flags are required on
any given line as the aliases require less parsing of nested cfg
expressions.

  • Drops 122 LoC.
  • Each cfg attribute is about half the width
  • No more complex any() within all() conditions

Introduces aliases for each feature flag as well as for the main
combinations of http version and client/server roles. This makes it
significantly easier to understand which feature flags are required on
any given line as the aliases require less parsing of nested `cfg`
expressions.
@seanmonstar
Copy link
Member

It does look cleaner, true! I'd rather not take a build-dependency, though.

I tried to make things easier with a few cfg_blah macros, but they didn't work out well in practice.

@joshka
Copy link
Author

joshka commented Mar 27, 2025

It does look cleaner, true! I'd rather not take a build-dependency, though.

My personal calculus would be to rely on a well used crate like this (69M downloads on 6 versions, vs hyper's 278M on 244 versions), but I can see why you might want to avoid that in hyper. I will expand the macros into their effect. It's mostly just a feature gated println.

I tried to make things easier with a few cfg_blah macros, but they didn't work out well in practice.

I think there's some useful ideas there in grouping code and adding doc comments, but using doc_auto_cfg is often a bit simpler unless there's specific situations where that doesn't work.

#[cfg(feature = "runtime")]
#[cfg(runtime)]
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Doing some more digging, this feature doesn't exist in the cargo.toml. Should it be removed from the source or added to the features list?

@seanmonstar
Copy link
Member

I should have said more! Not just a build-dependency, but the build script. There's a light positive pressure generally to avoid them unless building something, because they slow down compiler parallelism.

@joshka
Copy link
Author

joshka commented Mar 27, 2025

I should have said more! Not just a build-dependency, but the build script. There's a light positive pressure generally to avoid them unless building something, because they slow down compiler parallelism.

That sounds like a good general rule to follow, but also one which leads to negative outcomes if followed without thinking (let's not fall into a "cargo" cult mentality here). Can we measure the impact of this change on build times perhaps? I would intuitively expect that it's so negligible as to be meaningless as a blocker, but I'm often surprised when such assumptions are false. How would you confirm that that? Do you perhaps have a very low CPU system that you can point at that would show the difference like a Raspberry Pi 1 or similar?

The difference in compilation times (Macbook M2) are about 150ms, which puts it in a range where it wouldn't worry me generally. I'd make the tradeoff for simplifying code like this for +150ms build in just about any situation. Do you have a threshold that differs?

~/local/hyper on master
❯ time (touch src/lib.rs && cargo b)
   Compiling hyper v1.6.0 (/Users/joshka/local/hyper)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.14s
( touch src/lib.rs && cargo b; )  0.09s user 0.12s system 115% cpu 0.186 total

~/local/hyper on jm/cfg-aliases
❯ time (touch src/lib.rs && cargo b)
   Compiling hyper v1.6.0 (/Users/joshka/local/hyper)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.30s
( touch src/lib.rs && cargo b; )  0.10s user 0.13s system 65% cpu 0.350 total

@hanna-kruppe
Copy link

That's the wrong benchmark: it's measuring (1) incremental builds (2) with a no-op change to the library itself but none to the build script, but (3) unnecessarily including the cost of cargo asking git for changes that may require rebuilding the build script (because there's no cargo:rerun-if-changed=... output). For the many thousands projects that depend on hyper, what matters is the scenario where hyper and its build script are both built from scratch. Besides the impact that has on critical path length, the time for building and linking even trivial build scripts can be significant as previously discussed e.g. in rust-lang/log#489

@joshka
Copy link
Author

joshka commented Apr 1, 2025

Can you provide something that shows the concrete impact of this problem?

This is doing a full rebuild of hyper directly, and I'll add one where hyper is part of a dependency chain (if you've got a particular project you can point at where it's deep in the chain that would be great).

❯ hyperfine --parameter-list branch master,jm/cfg-aliases --prepare "git switch {branch} && cargo clean" "cargo build"
Benchmark 1: cargo build (branch = master)
  Time (mean ± σ):     843.7 ms ±  12.3 ms    [User: 1608.4 ms, System: 242.3 ms]
  Range (min … max):   831.9 ms … 863.3 ms    10 runs

Benchmark 2: cargo build (branch = jm/cfg-aliases)
  Time (mean ± σ):     848.7 ms ±  11.0 ms    [User: 1716.9 ms, System: 292.5 ms]
  Range (min … max):   835.6 ms … 865.9 ms    10 runs

Summary
  cargo build (branch = master) ran
    1.01 ± 0.02 times faster than cargo build (branch = jm/cfg-aliases)

with --all-features

❯ hyperfine --parameter-list branch master,jm/cfg-aliases --prepare "git switch {branch} && cargo clean" "RUSTFLAGS='--cfg hyper_unstable_tracing --cfg hyper_unstable_ffi' cargo build --all-features"
Benchmark 1: RUSTFLAGS='--cfg hyper_unstable_tracing --cfg hyper_unstable_ffi' cargo build --all-features (branch = master)
  Time (mean ± σ):      4.024 s ±  0.100 s    [User: 9.670 s, System: 1.361 s]
  Range (min … max):    3.876 s …  4.173 s    10 runs

Benchmark 2: RUSTFLAGS='--cfg hyper_unstable_tracing --cfg hyper_unstable_ffi' cargo build --all-features (branch = jm/cfg-aliases)
  Time (mean ± σ):      4.228 s ±  0.108 s    [User: 10.026 s, System: 1.458 s]
  Range (min … max):    4.112 s …  4.447 s    10 runs

Summary
  RUSTFLAGS='--cfg hyper_unstable_tracing --cfg hyper_unstable_ffi' cargo build --all-features (branch = master) ran
    1.05 ± 0.04 times faster than RUSTFLAGS='--cfg hyper_unstable_tracing --cfg hyper_unstable_ffi' cargo build --all-features (branch = jm/cfg-aliases)

Edit:

I picked tokio-console as it uses hyper via tonic -> axum -> reqwest -> hyper (and a variety of other paths)

❯ git d tokio-console/Cargo.toml
diff --git i/tokio-console/Cargo.toml w/tokio-console/Cargo.toml
index fc9ddf0..f6e1169 100644
--- i/tokio-console/Cargo.toml
+++ w/tokio-console/Cargo.toml
@@ -59,6 +59,7 @@ serde = { version = "1.0.145", features = ["derive"] }
 toml = "0.5"
 dirs = "5"
 hyper-util = { version = "0.1.6", features = ["tokio"] }
+hyper = { path = "../../hyper" }

 [dev-dependencies]
 trycmd = "0.15.4"

❯ hyperfine --parameter-list branch master,jm/cfg-aliases --prepare "(cd ../hyper; git switch {branch}); cargo clean" "cargo build"
Benchmark 1: cargo build (branch = master)
  Time (mean ± σ):     16.617 s ±  0.293 s    [User: 100.987 s, System: 11.485 s]
  Range (min … max):   16.172 s … 17.060 s    10 runs

Benchmark 2: cargo build (branch = jm/cfg-aliases)
  Time (mean ± σ):     16.778 s ±  0.849 s    [User: 101.437 s, System: 11.538 s]
  Range (min … max):   15.978 s … 19.031 s    10 runs

  Warning: Statistical outliers were detected. Consider re-running this benchmark on a quiet system without any interferences from other programs. It might help to use the '--warmup' or '--prepare' options.

Summary
  cargo build (branch = master) ran
    1.01 ± 0.05 times faster than cargo build (branch = jm/cfg-aliases)

This shows that this presents a measurable, but not noticeable at a human scale time difference. When is that meaningful? Serious question as I don't know what downstream concerns would be affected by this.

@hanna-kruppe
Copy link

hanna-kruppe commented Apr 2, 2025

Your diff doesn't replace hyper in the dependency tree of tokio-console, it adds the local version on top (you can verify this with cargo tree -i hyper). That version also has no features enabled, and I believe path dependencies are treated somewhat differently by Cargo compared to git and registry dependencies. The right way to test this is the [patch] table (which also requires updating the existing hyper dependency to match the version in git), like this:

diff --git a/Cargo.lock b/Cargo.lock
index bc83a65..8daee0f 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -821,9 +821,8 @@ dependencies = [

 [[package]]
 name = "hyper"
-version = "1.5.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bbbff0a806a4728c99295b254c8838933b5b082d75e3cb70c8dab21fdfbcfa9a"
+version = "1.6.0"
+source = "git+https://github.com/joshka/hyper.git?rev=83488c120d10dfd5a92aa5f6ff7f92355a9ebba1#83488c120d10dfd5a92aa5f6ff7f92355a9ebba1"
 dependencies = [
  "bytes",
  "futures-channel",
diff --git a/Cargo.toml b/Cargo.toml
index 1eb3e8a..4b9ff78 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -32,3 +32,6 @@ install-updater = false
 [profile.dist]
 inherits = "release"
 lto = "thin"
+
+[patch.crates-io]
+hyper = { git = "https://github.com/joshka/hyper.git", rev = "83488c120d10dfd5a92aa5f6ff7f92355a9ebba1" }

With this change, I get these numbers (your branch first, hyper 1.5.0 from crates.io second):

$ hyperfine --prepare 'cargo clean' 'cargo build'
Benchmark 1: cargo build
  Time (mean ± σ):     38.198 s ±  0.501 s    [User: 229.797 s, System: 19.679 s]
  Range (min … max):   37.632 s … 39.110 s    10 runs
$ git stash -- Cargo.toml Cargo.lock
Saved working directory and index state WIP on main: 2bd1afd update(api,subscriber)!: upgrade tonic to 0.13 (#615)
$ hyperfine --prepare 'cargo clean' 'cargo build'
Benchmark 1: cargo build
  Time (mean ± σ):     38.053 s ±  0.230 s    [User: 229.485 s, System: 19.600 s]
  Range (min … max):   37.769 s … 38.387 s    10 runs

Is this significant? It's probably hard for a human to notice because 38 seconds is way outside "I sit there watching the progress bar fill up" territory. On the other hand, if you multiply the CPU time by how many times hyper is compiled every month, it doesn't seem negligible any more. (The Rust ecosystem in general scores pretty terribly on this metric, but that's no reason to ignore it entirely.) Note that this computer has a CPU from 2017 (Intel i7-6700K) but it was high end back then and still works perfectly fine for most things -- definitely better than the newer laptop I sometimes use for coding while traveling.

That aside, tokio-console has a relatively large dependency graph (245 "units" in cargo build --timings) and isn't exactly a small and lightweight thing to compile (far from the worst in the Rust world, but 38 seconds is nothing to sneeze at). I'm personally much more interested in the lower end of the scale, basically what you need for a minimal HTTP1 server with hyper + tokio and little else. (Or HTTP1 client, but for that there's also ureq as lighter alternative). I would have liked to test the effect your branch has for something a binary with only these dependencies (with main.rs adapted from hyper's hello world example):

[dependencies]
bytes = "1.8.0"
http-body-util = "0.1.2"
hyper = { version = "1.5.0", features = ["http1", "server"] }
hyper-util = { version = "0.1.10", features = ["tokio"] }
tokio = { version = "1.41.1", features = ["net", "rt-multi-thread"] }

... but unfortunately your branch doesn't compile with that feature set:

   Compiling hyper v1.6.0 (https://github.com/joshka/hyper.git?rev=83488c120d10dfd5a92aa5f6ff7f92355a9ebba1#83488c12)
error[E0004]: non-exhaustive patterns: `error::Kind::User(User::DispatchGone)` not covered
   --> /home/hanna/.cargo/git/checkouts/hyper-8d72082f643922ed/83488c1/src/error.rs:369:15
    |
369 |         match self.inner.kind {
    |               ^^^^^^^^^^^^^^^ pattern `error::Kind::User(User::DispatchGone)` not covered
    |

Regardless, if the absolute impact were to be similar to the effect I saw in tokio-console, I'd consider that noteworthy on a project that otherwise builds in 4 seconds. In this example it probably won't have the same wall time impact because there's a lot of time where everything waits on tokio and there's spare CPU cores to use for a small build script. But I have other projects (not public) that build in 10 seconds or so where I might use hyper in the future which have more dependencies and more than enough work for all my CPU cores during most of the build.

src/error.rs Outdated
Comment on lines 159 to 138
#[cfg(all(feature = "client", any(feature = "http1", feature = "http2")))]
#[cfg(any(http1_client, http1_server))]
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is wrong, it should be http_client here.

@joshka
Copy link
Author

joshka commented Apr 2, 2025

... but unfortunately your branch doesn't compile with that feature set:

Looks like I failed on a boolean expansion somewhere (which is a pain because if I've missed one then all the expansions are now suspect). Mostly I did these by regex replacement, but it's entirely possible that I missed a bracket somewhere and broke things. If this was to be considered for merge, I'd do a comprehensive audit of the changes to ensure that this is correct.

@joshka
Copy link
Author

joshka commented Apr 2, 2025

Ugh on not noticing that it was using 1.5 there. Apologies.

I would have liked to test the effect your branch has for something a binary with only these dependencies (with main.rs adapted from hyper's hello world example):

I fixed just the buggy enum that you mentioned (noting that I'm not sure if this problem is more widespread - I'd have to check it out fully and redo the conversions to be sure)

Using the hello-world example from axum source code (@ latest commit as of now af6a595f)

diff --git i/Cargo.toml w/Cargo.toml
index cfbfb615..de75834b 100644
--- i/Cargo.toml
+++ w/Cargo.toml
@@ -53,3 +53,6 @@ verbose_file_reads = "warn"
 # These have been fixed in the past, but are still present in the changelog.
 DefaultOnFailedUpdgrade = "DefaultOnFailedUpdgrade"
 OnFailedUpdgrade = "OnFailedUpdgrade"
+
+[patch.crates-io]
+hyper = { version = "1.6.0", path = "../hyper" }
❯ cargo tree -p example-hello-world
\example-hello-world v0.1.0 (/Users/joshka/local/axum/examples/hello-world)
├── axum v0.8.3 (/Users/joshka/local/axum/axum)
│   ├── axum-core v0.5.2 (/Users/joshka/local/axum/axum-core)
│   │   ├── bytes v1.10.1
│   │   ├── futures-core v0.3.31
│   │   ├── http v1.3.1
│   │   │   ├── bytes v1.10.1
│   │   │   ├── fnv v1.0.7
│   │   │   └── itoa v1.0.15
│   │   ├── http-body v1.0.1
│   │   │   ├── bytes v1.10.1
│   │   │   └── http v1.3.1 (*)
│   │   ├── http-body-util v0.1.3
│   │   │   ├── bytes v1.10.1
│   │   │   ├── futures-core v0.3.31
│   │   │   ├── http v1.3.1 (*)
│   │   │   ├── http-body v1.0.1 (*)
│   │   │   └── pin-project-lite v0.2.16
│   │   ├── mime v0.3.17
│   │   ├── pin-project-lite v0.2.16
│   │   ├── rustversion v1.0.20 (proc-macro)
│   │   ├── sync_wrapper v1.0.2
│   │   ├── tower-layer v0.3.3
│   │   ├── tower-service v0.3.3
│   │   └── tracing v0.1.41
│   │       ├── log v0.4.27
│   │       ├── pin-project-lite v0.2.16
│   │       └── tracing-core v0.1.33
│   │           └── once_cell v1.21.3
│   ├── bytes v1.10.1
│   ├── form_urlencoded v1.2.1
│   │   └── percent-encoding v2.3.1
│   ├── futures-util v0.3.31
│   │   ├── futures-core v0.3.31
│   │   ├── futures-task v0.3.31
│   │   ├── pin-project-lite v0.2.16
│   │   └── pin-utils v0.1.0
│   ├── http v1.3.1 (*)
│   ├── http-body v1.0.1 (*)
│   ├── http-body-util v0.1.3 (*)
│   ├── hyper v1.6.0 (/Users/joshka/local/hyper)
│   │   ├── bytes v1.10.1
│   │   ├── futures-channel v0.3.31
│   │   │   └── futures-core v0.3.31
│   │   ├── futures-util v0.3.31 (*)
│   │   ├── http v1.3.1 (*)
│   │   ├── http-body v1.0.1 (*)
│   │   ├── httparse v1.10.1
│   │   ├── httpdate v1.0.3
│   │   ├── itoa v1.0.15
│   │   ├── pin-project-lite v0.2.16
│   │   ├── smallvec v1.14.0
│   │   └── tokio v1.44.1
│   │       ├── bytes v1.10.1
│   │       ├── libc v0.2.171
│   │       ├── mio v1.0.3
│   │       │   └── libc v0.2.171
│   │       ├── parking_lot v0.12.3
│   │       │   ├── lock_api v0.4.12
│   │       │   │   └── scopeguard v1.2.0
│   │       │   │   [build-dependencies]
│   │       │   │   └── autocfg v1.4.0
│   │       │   └── parking_lot_core v0.9.10
│   │       │       ├── cfg-if v1.0.0
│   │       │       ├── libc v0.2.171
│   │       │       └── smallvec v1.14.0
│   │       ├── pin-project-lite v0.2.16
│   │       ├── signal-hook-registry v1.4.2
│   │       │   └── libc v0.2.171
│   │       ├── socket2 v0.5.9
│   │       │   └── libc v0.2.171
│   │       └── tokio-macros v2.5.0 (proc-macro)
│   │           ├── proc-macro2 v1.0.94
│   │           │   └── unicode-ident v1.0.18
│   │           ├── quote v1.0.40
│   │           │   └── proc-macro2 v1.0.94 (*)
│   │           └── syn v2.0.100
│   │               ├── proc-macro2 v1.0.94 (*)
│   │               ├── quote v1.0.40 (*)
│   │               └── unicode-ident v1.0.18
│   ├── hyper-util v0.1.11
│   │   ├── bytes v1.10.1
│   │   ├── futures-util v0.3.31 (*)
│   │   ├── http v1.3.1 (*)
│   │   ├── http-body v1.0.1 (*)
│   │   ├── hyper v1.6.0 (/Users/joshka/local/hyper) (*)
│   │   ├── pin-project-lite v0.2.16
│   │   ├── tokio v1.44.1 (*)
│   │   └── tower-service v0.3.3
│   ├── itoa v1.0.15
│   ├── matchit v0.8.4
│   ├── memchr v2.7.4
│   ├── mime v0.3.17
│   ├── percent-encoding v2.3.1
│   ├── pin-project-lite v0.2.16
│   ├── rustversion v1.0.20 (proc-macro)
│   ├── serde v1.0.219
│   ├── serde_json v1.0.140
│   │   ├── itoa v1.0.15
│   │   ├── memchr v2.7.4
│   │   ├── ryu v1.0.20
│   │   └── serde v1.0.219
│   ├── serde_path_to_error v0.1.17
│   │   ├── itoa v1.0.15
│   │   └── serde v1.0.219
│   ├── serde_urlencoded v0.7.1
│   │   ├── form_urlencoded v1.2.1 (*)
│   │   ├── itoa v1.0.15
│   │   ├── ryu v1.0.20
│   │   └── serde v1.0.219
│   ├── sync_wrapper v1.0.2
│   ├── tokio v1.44.1 (*)
│   ├── tower v0.5.2
│   │   ├── futures-core v0.3.31
│   │   ├── futures-util v0.3.31 (*)
│   │   ├── pin-project-lite v0.2.16
│   │   ├── sync_wrapper v1.0.2
│   │   ├── tokio v1.44.1 (*)
│   │   ├── tower-layer v0.3.3
│   │   ├── tower-service v0.3.3
│   │   └── tracing v0.1.41 (*)
│   ├── tower-layer v0.3.3
│   ├── tower-service v0.3.3
│   └── tracing v0.1.41 (*)
└── tokio v1.44.1 (*)
❯ hyperfine --parameter-list branch master,jm/cfg-aliases --prepare "(cd ../hyper; git switch {branch}); cargo clean" "cargo build -p example-hello-world"
Benchmark 1: cargo build -p example-hello-world (branch = master)
  Time (mean ± σ):      7.422 s ±  0.123 s    [User: 20.397 s, System: 3.141 s]
  Range (min … max):    7.314 s …  7.708 s    10 runs

Benchmark 2: cargo build -p example-hello-world (branch = jm/cfg-aliases)
  Time (mean ± σ):      7.892 s ±  0.125 s    [User: 21.126 s, System: 3.259 s]
  Range (min … max):    7.748 s …  8.123 s    10 runs

Summary
  cargo build -p example-hello-world (branch = master) ran
    1.06 ± 0.02 times faster than cargo build -p example-hello-world (branch = jm/cfg-aliases)

Is this significant? It's probably hard for a human to notice because 38 seconds is way outside "I sit there watching the progress bar fill up" territory. On the other hand, if you multiply the CPU time by how many times hyper is compiled every month, it doesn't seem negligible any more. (The Rust ecosystem in general scores pretty terribly on this metric, but that's no reason to ignore it entirely.) Note that this computer has a CPU from 2017 (Intel i7-6700K) but it was high end back then and still works perfectly fine for most things -- definitely better than the newer laptop I sometimes use for coding while traveling.

I think it would be worth setting an actual target of what's good enough performance for build rather than this being some ambiguous idea. Without this everyone's tradeoffs are invisible constraints that are impossible to meet. I'm running a max spec-ed at the time, but now 2 years old laptop here as my daily, which I acknowledge probably still puts it still well into the upper end of the performance bracket. For me personally the time vs complexity tradeoff is an obvious win on the side of simplicity here. I'd say that anything sub second is immaterial here.

@hanna-kruppe
Copy link

hanna-kruppe commented Apr 6, 2025

I'm only a user of hyper, so I can't say anything worthwhile about how to balance improved maintainability (which only matters directly for maintainers) with build time regressions. If I was a maintainer, I could look at the value this provides to me and try to come up with a number for how much build time I'm willing to trade for it. I trust that @seanmonstar and others will do this in the best way for their own health and the health of the project.

But as user of hyper, there is no measurable regression I'd unconditionally accept as immaterial for me. My projects depend on many crates, not just hyper, and overall build time is influenced by all of them -- both by how long each crate takes to build in isolation and by interactions of the overall dependency graph (available parallelism, critical paths, feature unification, etc.). If every one of my 200 dependencies accepted a 5% or 0.1s build regression because none of them are noticeable for humans, build times for my project would get very noticeably slower.

Naturally, this doesn't apply equally to all dependencies: it's more effective to spend effort on keeping build times low for crates that are compiled often and have non-trivial impact on downstream build times. Hyper scores relatively high on both metrics because of its popularity and because of its position in the dependency graph (downstream of tokio, upstream of most of the HTTP-related Rust ecosystem). That's why I thought it was worth my time to engage here in the first place. But again, that's just my priorities as a user of hyper: weighing that against maintainability of the code base is the maintainers' job.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants