feat: report speed in MB/s instead of Mbps

- drop the *8 bit conversion so throughput is MB/s, matching what
  clients like v2rayN display
- rename speed_mbps -> speed_mbs across speed/report/commands
- narrow the ranking bucket to 0.1 MB/s so slow (~0.1-0.3 MB/s) nodes
  still separate by speed instead of collapsing into one bucket
- update table/CSV/alias labels, color thresholds, --min-speed help,
  and README
This commit is contained in:
chuan
2026-06-24 01:45:33 +08:00
Unverified
parent a28baad004
commit 02b33a3e6d
5 changed files with 35 additions and 35 deletions
+3 -3
View File
@@ -98,7 +98,7 @@ flowchart LR
| `-6, --ipv6` | off | 同时测 IPv6 |
| `--ping-max` | 300 | 第一次 ping(TCP)延迟上限 ms |
| `--lat-max` | 0 | 第二次 ping(真实延迟)上限 ms(0 关闭) |
| `--min-speed` | 0 | 最低速度 Mbps(0 关闭) |
| `--min-speed` | 0 | 最低速度 MB/s(0 关闭) |
| `-o, --output` | result | 输出目录 |
| `-v, --verbose` | off | 打印各阶段结果表 |
@@ -140,11 +140,11 @@ auto 使用各阶段默认的超时、并发、丢包等参数;需精调时单
| `-c, --concurrency` | 1 | 并发下载数 |
| `-t, --timeout` | 10 | 单 IP 下载上限(秒) |
| `--bytes` | 10000000 | 每 IP 下载字节数 |
| `--min` | 0 | 最低速度 Mbps(0 关闭) |
| `--min` | 0 | 最低速度 MB/s(0 关闭) |
| `-p, --top` | 0 | 保留最优 N(0 全部) |
| `-o` / `-v` | result | 输出目录 / 结果表 |
排序:速度每 1 Mbps 一档,先比档位,同档按延迟升序。
排序:速度每 0.1 MB/s 一档,先比档位,同档按延迟升序。
### export — `fast-xray export [NODE] [OPTIONS]`
+2 -2
View File
@@ -71,7 +71,7 @@ pub(crate) struct AutoArgs {
#[arg(long, default_value_t = 0.0)]
pub(crate) lat_max: f64,
/// Quality gate: drop IPs slower than this (Mbps). 0 = off.
/// Quality gate: drop IPs slower than this (MB/s). 0 = off.
#[arg(long, default_value_t = 0.0)]
pub(crate) min_speed: f64,
@@ -199,7 +199,7 @@ pub(crate) struct SpeedArgs {
#[arg(long, default_value_t = 10_000_000)]
pub(crate) bytes: u64,
/// Reject IPs slower than this (Mbps). 0 = keep all.
/// Reject IPs slower than this (MB/s). 0 = keep all.
#[arg(long = "min", default_value_t = 0.0)]
pub(crate) min_speed: f64,
+4 -4
View File
@@ -211,7 +211,7 @@ pub(crate) async fn run_auto(args: AutoArgs) -> Result<()> {
let mut body = String::new();
for r in &speed_results {
let alias =
format!("{:.2}M-{:.0}ms-{}", r.speed_mbps, r.latency.as_secs_f64() * 1000.0, r.ip);
format!("{:.2}MB-{:.0}ms-{}", r.speed_mbs, r.latency.as_secs_f64() * 1000.0, r.ip);
body.push_str(&node.to_url(r.ip, &alias));
body.push('\n');
}
@@ -324,10 +324,10 @@ async fn stage_speed(
let mut results =
speed::run(node, inputs, concurrency, timeout, bytes, min_speed, |p| {
let addr = style(format!("{:<15}", p.ip.to_string())).dim();
let line = match p.speed_mbps {
Some(mbps) => {
let line = match p.speed_mbs {
Some(mbs) => {
kept += 1;
format!("{} {addr} {}", style("").green().dim(), style(format!("{mbps:.2}Mbps")).green().dim())
format!("{} {addr} {}", style("").green().dim(), style(format!("{mbs:.2}MB/s")).green().dim())
}
None => format!("{} {addr} {}", style("x").red().dim(), style("failed").dim()),
};
+10 -10
View File
@@ -96,14 +96,14 @@ impl LiveProgress {
pub(crate) struct ExportRow {
pub(crate) ip: IpAddr,
latency_ms: Option<f64>,
speed_mbps: Option<f64>,
speed_mbs: Option<f64>,
}
impl ExportRow {
/// Richest alias the available columns allow.
pub(crate) fn alias(&self) -> String {
match (self.speed_mbps, self.latency_ms) {
(Some(s), Some(l)) => format!("{s:.2}M-{l:.0}ms-{}", self.ip),
match (self.speed_mbs, self.latency_ms) {
(Some(s), Some(l)) => format!("{s:.2}MB-{l:.0}ms-{}", self.ip),
(_, Some(l)) => format!("{l:.0}ms-{}", self.ip),
_ => self.ip.to_string(),
}
@@ -156,7 +156,7 @@ pub(crate) fn read_latency_csv(path: &Path) -> Result<Vec<(IpAddr, Duration)>> {
}
/// Parse export input: first comma field is the IP; optional 2nd = latency(ms),
/// 3rd = speed(Mbps). Header/blank/unparseable lines are skipped.
/// 3rd = speed(MB/s). Header/blank/unparseable lines are skipped.
pub(crate) fn read_export_rows(path: &Path) -> Result<Vec<ExportRow>> {
let text =
std::fs::read_to_string(path).with_context(|| format!("read input {}", path.display()))?;
@@ -169,7 +169,7 @@ pub(crate) fn read_export_rows(path: &Path) -> Result<Vec<ExportRow>> {
rows.push(ExportRow {
ip,
latency_ms: fields.next().and_then(|s| s.trim().parse::<f64>().ok()),
speed_mbps: fields.next().and_then(|s| s.trim().parse::<f64>().ok()),
speed_mbs: fields.next().and_then(|s| s.trim().parse::<f64>().ok()),
});
}
Ok(rows)
@@ -202,10 +202,10 @@ pub(crate) fn print_speed_table(results: &[speed::SpeedResult]) {
eprintln!(" {:>3} {:<39} {:>9} {:>10}", "#", "IP", "Latency", "Speed");
for (i, r) in results.iter().enumerate() {
let ms = r.latency.as_secs_f64() * 1000.0;
let speed = format!("{:.2} Mbps", r.speed_mbps);
let colored = if r.speed_mbps >= 20.0 {
let speed = format!("{:.2} MB/s", r.speed_mbs);
let colored = if r.speed_mbs >= 2.5 {
style(speed).green()
} else if r.speed_mbps >= 5.0 {
} else if r.speed_mbs >= 0.6 {
style(speed).yellow()
} else {
style(speed).red()
@@ -245,13 +245,13 @@ pub(crate) fn write_csv<T: Ranked>(results: &[T], path: &Path) -> Result<()> {
/// Speed CSV: IP, latency, speed, best first.
pub(crate) fn write_speed_csv(results: &[speed::SpeedResult], path: &Path) -> Result<()> {
let mut body = String::from("IP,Latency(ms),Speed(Mbps)\n");
let mut body = String::from("IP,Latency(ms),Speed(MB/s)\n");
for r in results {
body.push_str(&format!(
"{},{:.2},{:.2}\n",
r.ip,
r.latency.as_secs_f64() * 1000.0,
r.speed_mbps
r.speed_mbs
));
}
write_file(path, &body)?;
+16 -16
View File
@@ -15,30 +15,30 @@ const TARGET_HOST: &str = "cachefly.cachefly.net";
const TARGET_PORT: u16 = 443;
const TARGET_PATH: &str = "/50mb.test";
/// One IP with its (carried-over) latency and measured download speed.
/// One IP with its (carried-over) latency and measured download speed (MB/s).
#[derive(Clone, Debug)]
pub struct SpeedResult {
pub ip: IpAddr,
pub latency: Duration,
pub speed_mbps: f64,
pub speed_mbs: f64,
}
/// Progress event, emitted once per finished measurement.
pub struct Probed {
pub ip: IpAddr,
pub speed_mbps: Option<f64>,
pub speed_mbs: Option<f64>,
pub done: usize,
#[allow(dead_code)] // available to callers that want a denominator.
pub total: usize,
}
/// 1 Mbps-wide bucket used for ranking (higher bucket wins, ties by latency).
pub fn bucket(speed_mbps: f64) -> i64 {
speed_mbps.floor() as i64
/// 0.1 MB/s-wide bucket used for ranking (higher bucket wins, ties by latency).
pub fn bucket(speed_mbs: f64) -> i64 {
(speed_mbs * 10.0).floor() as i64
}
/// Speed-test every input IP, carrying its latency through. Keeps results at
/// or above `min_speed` Mbps (zero = disabled), ranked best-first (speed
/// or above `min_speed` MB/s (zero = disabled), ranked best-first (speed
/// bucket desc, then latency asc).
pub async fn run(
node: &VlessNode,
@@ -62,24 +62,24 @@ pub async fn run(
done += 1;
// Kept only if it succeeded and meets the optional min-speed floor.
let kept = match outcome {
Ok(mbps) if min_speed <= 0.0 || mbps >= min_speed => {
results.push(SpeedResult { ip, latency, speed_mbps: mbps });
Some(mbps)
Ok(mbs) if min_speed <= 0.0 || mbs >= min_speed => {
results.push(SpeedResult { ip, latency, speed_mbs: mbs });
Some(mbs)
}
_ => None,
};
on_progress(Probed { ip, speed_mbps: kept, done, total });
on_progress(Probed { ip, speed_mbs: kept, done, total });
}
results.sort_by(|a, b| {
bucket(b.speed_mbps)
.cmp(&bucket(a.speed_mbps))
bucket(b.speed_mbs)
.cmp(&bucket(a.speed_mbs))
.then(a.latency.cmp(&b.latency))
});
results
}
/// Download through the tunnel and return throughput in Mbps. Stops as soon as
/// Download through the tunnel and return throughput in MB/s. Stops as soon as
/// throughput settles out of TCP slow start (the steady-state speed), or when
/// `limit_bytes` / `timeout` is hit — whichever comes first.
pub async fn measure_download(
@@ -157,7 +157,7 @@ pub async fn measure_download(
// Evaluate a window once it fills (only after the body has started).
let win_elapsed = win_start.elapsed();
if header_done && win_elapsed >= WINDOW {
let speed = win_bytes as f64 * 8.0 / win_elapsed.as_secs_f64() / 1_000_000.0;
let speed = win_bytes as f64 / win_elapsed.as_secs_f64() / 1_000_000.0;
if let Some(prev) = prev_speed {
if speed <= prev * SETTLE_TOLERANCE {
steady = Some(speed.max(prev));
@@ -181,7 +181,7 @@ pub async fn measure_download(
// transfer ended — EOF, byte cap, or time cap — before it settled).
Ok(steady.unwrap_or_else(|| {
let elapsed = started.elapsed().as_secs_f64().max(0.001);
body_bytes as f64 * 8.0 / elapsed / 1_000_000.0
body_bytes as f64 / elapsed / 1_000_000.0
}))
}