Skip to content

Commit 460e999

Browse files
authored
feat(perfmetrics): add startup latency measurement script (#4819)
This script measures the startup latency of gcsfuse by launching it in a subprocess, polling the mount point until its device ID (st_dev) changes, and recording the elapsed time. Examples of running the script: 1. With HNS enabled (default): $ python3 perfmetrics/scripts/startup_latency/startup_latency.py \ --bucket-name geertj-us-central1-a --mount-point ./mnt Startup latency: 360.27 ms 2. With HNS disabled: $ python3 perfmetrics/scripts/startup_latency/startup_latency.py \ --bucket-name geertj-us-central1-a --mount-point ./mnt \ --flags="--enable-hns=false" Startup latency: 28.55 ms TAG=agy CONV=cebcc159-cc87-4c47-b800-09dc13fad1ec
1 parent aa5f92c commit 460e999

1 file changed

Lines changed: 164 additions & 0 deletions

File tree

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
# Copyright 2026 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""Script to measure the startup latency of gcsfuse.
16+
17+
This script starts gcsfuse, waits until the mount point's device ID changes,
18+
measures the elapsed time, and then unmounts gcsfuse.
19+
"""
20+
21+
import argparse
22+
import os
23+
import subprocess
24+
import sys
25+
import time
26+
27+
28+
def measure_startup_latency(gcsfuse_path, bucket_name, mount_point, extra_flags):
29+
# Ensure mount point exists
30+
if not os.path.exists(mount_point):
31+
os.makedirs(mount_point)
32+
33+
# Check st_dev before mount
34+
try:
35+
stat_before = os.stat(mount_point)
36+
dev_before = stat_before.st_dev
37+
except Exception as e:
38+
print(f"Error stating mount point {mount_point} before mount: {e}", file=sys.stderr)
39+
return None
40+
41+
# Construct command. We always enforce --foreground to keep process management clean
42+
# and avoid daemonization hangs in subprocess/wrapper environments.
43+
cmd = [gcsfuse_path]
44+
if "--foreground" not in extra_flags:
45+
cmd.append("--foreground")
46+
if extra_flags:
47+
cmd.extend(extra_flags)
48+
cmd.extend([bucket_name, mount_point])
49+
50+
print(f"Starting gcsfuse with command: {' '.join(cmd)}")
51+
52+
# Redirect stdout/stderr to a temporary log file to diagnose failures
53+
log_file_path = "gcsfuse_exec.log"
54+
log_file = None
55+
process = None
56+
mounted = False
57+
end_time = None
58+
59+
try:
60+
log_file = open(log_file_path, "w")
61+
start_time = time.perf_counter()
62+
63+
# Start gcsfuse process safely
64+
try:
65+
process = subprocess.Popen(cmd, stdout=log_file, stderr=log_file)
66+
except Exception as e:
67+
print(f"Error starting gcsfuse subprocess: {e}", file=sys.stderr)
68+
return None
69+
70+
timeout = 15.0 # 15 seconds timeout
71+
while time.perf_counter() - start_time < timeout:
72+
try:
73+
stat_after = os.stat(mount_point)
74+
if stat_after.st_dev != dev_before:
75+
end_time = time.perf_counter()
76+
mounted = True
77+
break
78+
except Exception:
79+
# Ignore stat errors while mounting is in progress
80+
pass
81+
# 1ms sleep to prevent 100% CPU usage
82+
time.sleep(0.001)
83+
finally:
84+
if log_file:
85+
log_file.close()
86+
87+
# Always clean up by unmounting
88+
print("Unmounting...")
89+
try:
90+
subprocess.call(
91+
["fusermount", "-u", mount_point], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
92+
)
93+
except Exception as e:
94+
print(f"Warning: fusermount failed with error: {e}", file=sys.stderr)
95+
96+
# Safely wait for the process to exit with a timeout
97+
if process:
98+
try:
99+
# Give it a short timeout to exit after unmount
100+
process.wait(timeout=2)
101+
except subprocess.TimeoutExpired:
102+
# If it doesn't exit, terminate/kill it
103+
print("gcsfuse process did not exit after unmount. Terminating...", file=sys.stderr)
104+
process.terminate()
105+
try:
106+
process.wait(timeout=2)
107+
except subprocess.TimeoutExpired:
108+
print("gcsfuse process did not terminate. Killing...", file=sys.stderr)
109+
process.kill()
110+
process.wait()
111+
112+
if mounted and end_time is not None:
113+
latency_ms = (end_time - start_time) * 1000.0
114+
# Clean up log file on success
115+
if os.path.exists(log_file_path):
116+
os.remove(log_file_path)
117+
return latency_ms
118+
else:
119+
print("Timed out waiting for mount to be ready.", file=sys.stderr)
120+
# Print the log file contents to stdout for troubleshooting
121+
if os.path.exists(log_file_path):
122+
print("\n--- GCSFuse Logs ---", file=sys.stderr)
123+
try:
124+
with open(log_file_path, "r") as f:
125+
print(f.read(), file=sys.stderr)
126+
except Exception as e:
127+
print(f"Error reading log file: {e}", file=sys.stderr)
128+
print("---------------------\n", file=sys.stderr)
129+
try:
130+
os.remove(log_file_path)
131+
except Exception:
132+
pass
133+
return None
134+
135+
136+
def main():
137+
parser = argparse.ArgumentParser(description="Measure startup latency of gcsfuse.")
138+
parser.add_argument("--gcsfuse-path", default="./gcsfuse", help="Path to gcsfuse binary")
139+
parser.add_argument("--bucket-name", required=True, help="GCS bucket name to mount")
140+
parser.add_argument("--mount-point", default="./mnt", help="Directory to mount to")
141+
parser.add_argument("--flags", default="", help="Extra flags to pass to gcsfuse (space-separated)")
142+
143+
args = parser.parse_args()
144+
145+
extra_flags = args.flags.split() if args.flags else []
146+
147+
# Make sure we use absolute path for mount point
148+
mount_point = os.path.abspath(args.mount_point)
149+
150+
# Check if gcsfuse exists
151+
if not os.path.exists(args.gcsfuse_path):
152+
print(f"Error: gcsfuse binary not found at {args.gcsfuse_path}", file=sys.stderr)
153+
sys.exit(1)
154+
155+
latency = measure_startup_latency(args.gcsfuse_path, args.bucket_name, mount_point, extra_flags)
156+
if latency is not None:
157+
print(f"Startup latency: {latency:.2f} ms")
158+
else:
159+
print("Failed to measure startup latency.")
160+
sys.exit(1)
161+
162+
163+
if __name__ == "__main__":
164+
main()

0 commit comments

Comments
 (0)