* [PATCH nfs-utils 0/4] exports: add support for new riomode= and wiomode= export options
@ 2026-10-27 16:50 Jeff Layton
2026-10-27 16:50 ` [PATCH nfs-utils 1/4] exportfs/mountd/exportd: track the svc_export policy highest attribute Jeff Layton
` (3 more replies)
0 siblings, 4 replies; 5+ messages in thread
From: Jeff Layton @ 2026-10-27 16:50 UTC (permalink / raw)
To: Steve Dickson, Chuck Lever, NeilBrown, Olga Kornievskaia, Dai Ngo,
Tom Talpey, Trond Myklebust, Anna Schumaker, Mike Snitzer
Cc: linux-nfs, Jeff Layton
This patchset adds support for riomode= and wiomode= export options to
exportd and mountd. This is the companion patchset to [1].
The last patch in this adds a new nfsd-iomode-advisor script that
watches various stats on the server and advises the admin what mode to
use.
I consider the advisor optional as it was largely vibe-coded. I'm not
sure I'd trust its advice that far, but it's probably better than
guessing.
[1]: https://lore.kernel.org/linux-nfs/20261027-export-iomode-v1-0-262ed932a036@kernel.org/
Signed-off-by: Jeff Layton <jlayton@kernel.org>
---
Jeff Layton (4):
exportfs/mountd/exportd: track the svc_export policy highest attribute
exports: Add riomode= and wiomode= export options
exportfs: Display and document riomode= and wiomode=
nfsd-iomode-advisor: recommend riomode= and wiomode= for an export
configure.ac | 3 +-
support/export/cache.c | 59 +-
support/export/export_policy.c | 29 +-
support/export/v4root.c | 2 +
support/include/exportfs.h | 3 +
support/include/nfsd_netlink.h | 12 +
support/include/nfslib.h | 14 +
support/nfs/exports.c | 51 +
tools/Makefile.am | 2 +-
tools/nfsd-iomode-advisor/Makefile.am | 14 +
tools/nfsd-iomode-advisor/nfsd-iomode-advisor.man | 232 ++++
tools/nfsd-iomode-advisor/nfsd-iomode-advisor.py | 1198 +++++++++++++++++++++
utils/exportfs/exportfs.c | 34 +
utils/exportfs/exports.man | 22 +
14 files changed, 1671 insertions(+), 4 deletions(-)
---
base-commit: 0ed4324cbbb8020039d338d597c1422a980f7085
change-id: 20261014-master-b5afd69e3772
Best regards,
--
Jeff Layton <jlayton@kernel.org>
^ permalink raw reply [flat|nested] 5+ messages in thread
* [PATCH nfs-utils 1/4] exportfs/mountd/exportd: track the svc_export policy highest attribute
2026-10-27 16:50 [PATCH nfs-utils 0/4] exports: add support for new riomode= and wiomode= export options Jeff Layton
@ 2026-10-27 16:50 ` Jeff Layton
2026-10-27 16:50 ` [PATCH nfs-utils 2/4] exports: Add riomode= and wiomode= export options Jeff Layton
` (2 subsequent siblings)
3 siblings, 0 replies; 5+ messages in thread
From: Jeff Layton @ 2026-10-27 16:50 UTC (permalink / raw)
To: Steve Dickson, Chuck Lever, NeilBrown, Olga Kornievskaia, Dai Ngo,
Tom Talpey, Trond Myklebust, Anna Schumaker, Mike Snitzer
Cc: linux-nfs, Jeff Layton
Record max_attr, the highest attribute in the kernel svc_export netlink
policy, in struct nfsd_export_policy. Follow-on work needs it to avoid
sending attributes that an older kernel would reject the whole entry
over.
Signed-off-by: Jeff Layton <jlayton@kernel.org>
---
support/export/export_policy.c | 7 ++++++-
support/include/exportfs.h | 2 ++
2 files changed, 8 insertions(+), 1 deletion(-)
diff --git a/support/export/export_policy.c b/support/export/export_policy.c
index 9c0a099fb0c2..a0170cb87f91 100644
--- a/support/export/export_policy.c
+++ b/support/export/export_policy.c
@@ -40,6 +40,7 @@ struct export_policy_dump {
int done;
int do_idx; /* the op's own policy */
/* per policy: */
+ int max_attr[NL_POLICY_SLOTS];
int reqs_idx[NL_POLICY_SLOTS]; /* REQUESTS nests this */
uint64_t flags_mask[NL_POLICY_SLOTS]; /* mask of FLAGS */
};
@@ -106,6 +107,8 @@ static void nl_policy_attrs(struct export_policy_dump *d, struct nlattr *attr)
nla_for_each_nested(a, pol, arem) {
int type = nla_type(a);
+ if (d->max_attr[idx] < type)
+ d->max_attr[idx] = type;
nla_for_each_nested(p, a, rem)
nl_policy_type_attr(d, idx, type, p);
}
@@ -219,8 +222,10 @@ int nfsd_export_policy_query(struct nfsd_export_policy *pol)
* every kernel with the netlink export interface.
*/
cached.secinfo_flags = NFSEXP_SECINFO_FLAGS;
+ cached.max_attr = d.max_attr[svc];
have_cached = true;
- xlog(D_NETLINK, "svc_export policy: flags 0x%x", cached.flags);
+ xlog(D_NETLINK, "svc_export policy: max attribute %d, flags 0x%x",
+ cached.max_attr, cached.flags);
*pol = cached;
ret = 0;
out:
diff --git a/support/include/exportfs.h b/support/include/exportfs.h
index d762e4533560..a7c145543075 100644
--- a/support/include/exportfs.h
+++ b/support/include/exportfs.h
@@ -170,6 +170,8 @@ struct nfskey * key_lookup(char *hname);
struct nfsd_export_policy {
unsigned int flags; /* export flags it knows */
unsigned int secinfo_flags; /* flags that may vary per flavor */
+ int max_attr; /* highest svc_export netlink attribute,
+ * 0 if it did not say */
};
struct nfsd_export_policy *get_export_features(void);
--
2.56.0
^ permalink raw reply related [flat|nested] 5+ messages in thread
* [PATCH nfs-utils 2/4] exports: Add riomode= and wiomode= export options
2026-10-27 16:50 [PATCH nfs-utils 0/4] exports: add support for new riomode= and wiomode= export options Jeff Layton
2026-10-27 16:50 ` [PATCH nfs-utils 1/4] exportfs/mountd/exportd: track the svc_export policy highest attribute Jeff Layton
@ 2026-10-27 16:50 ` Jeff Layton
2026-10-27 16:50 ` [PATCH nfs-utils 3/4] exportfs: Display and document riomode= and wiomode= Jeff Layton
2026-10-27 16:50 ` [PATCH nfs-utils 4/4] nfsd-iomode-advisor: recommend riomode= and wiomode= for an export Jeff Layton
3 siblings, 0 replies; 5+ messages in thread
From: Jeff Layton @ 2026-10-27 16:50 UTC (permalink / raw)
To: Steve Dickson, Chuck Lever, NeilBrown, Olga Kornievskaia, Dai Ngo,
Tom Talpey, Trond Myklebust, Anna Schumaker, Mike Snitzer
Cc: linux-nfs, Jeff Layton
Parse riomode=/wiomode= (buffered/dontcache/direct) into new
e_riomode/e_wiomode exportent fields, defaulting to unset, and pass
explicitly-set values to the kernel over the netlink svc_export
interface.
- Kernels without the iomode attributes (e.g. v7.2) reject the whole
entry. Add nfsd_export_iomode_supported(), which checks the
svc_export policy's highest attribute via nfsd_export_policy_query(),
and have mountd drop the options with a warning on such kernels
instead of leaving the export unmountable.
- The /proc channel cannot carry them either; warn there too.
- Resync nfsd_netlink.h and bump the configure probe to
NFSD_A_SVC_EXPORT_WRITE_IOMODE.
Signed-off-by: Jeff Layton <jlayton@kernel.org>
---
configure.ac | 2 +-
support/export/cache.c | 59 +++++++++++++++++++++++++++++++++++++++++-
support/export/export_policy.c | 22 ++++++++++++++++
support/export/v4root.c | 2 ++
support/include/exportfs.h | 1 +
support/include/nfsd_netlink.h | 12 +++++++++
support/include/nfslib.h | 13 ++++++++++
support/nfs/exports.c | 51 ++++++++++++++++++++++++++++++++++++
8 files changed, 160 insertions(+), 2 deletions(-)
diff --git a/configure.ac b/configure.ac
index 2c13292961bf..ff752b8bd17f 100644
--- a/configure.ac
+++ b/configure.ac
@@ -283,7 +283,7 @@ AC_CHECK_HEADERS(linux/nfsd_netlink.h)
# ensure the system netlink headers have the latest features
AC_COMPILE_IFELSE([AC_LANG_PROGRAM([[#include <linux/nfsd_netlink.h>]],
- [[int foo = NFSD_CMD_FILEHANDLE_GET;]])],
+ [[int foo = NFSD_A_SVC_EXPORT_WRITE_IOMODE;]])],
[AC_DEFINE([USE_SYSTEM_NFSD_NETLINK_H], 1,
["Use system's linux/nfsd_netlink.h"])])
AC_COMPILE_IFELSE([AC_LANG_PROGRAM([[#include <linux/lockd_netlink.h>]],
diff --git a/support/export/cache.c b/support/export/cache.c
index 3c7ee9f90ac1..86b8da135837 100644
--- a/support/export/cache.c
+++ b/support/export/cache.c
@@ -1287,6 +1287,9 @@ static struct nl_sock *nfsd_nl_notify_sock; /* multicast notifications */
static struct nl_sock *nfsd_nl_cmd_sock; /* GET_REQS / SET_REQS commands */
static int nfsd_nl_family;
+/* Does the running kernel accept riomode/wiomode in SET_REQS? */
+static bool nfsd_nl_iomode_ok;
+
#define NL_BUFFER_SIZE 65536
struct export_req {
@@ -1380,8 +1383,10 @@ static int cache_nfsd_nl_open(void)
ret = cache_genl_open(NFSD_FAMILY_NAME, NFSD_MCGRP_EXPORTD,
&nfsd_nl_cmd_sock, &nfsd_nl_notify_sock,
&nfsd_nl_family);
- if (ret == 0)
+ if (ret == 0) {
nfsd_nl_load_export_features();
+ nfsd_nl_iomode_ok = nfsd_export_iomode_supported() > 0;
+ }
return ret;
}
@@ -1648,6 +1653,40 @@ static int nfsd_nl_add_xprtsec(struct nl_msg *msg, struct exportent *ep)
return 0;
}
+static int nfsd_nl_add_iomode(struct nl_msg *msg, struct exportent *ep)
+{
+ static bool warned;
+
+ if (ep->e_riomode == NFSD_IO_UNSET && ep->e_wiomode == NFSD_IO_UNSET)
+ return 0;
+
+ /*
+ * A kernel without per-export I/O modes would refuse the entry,
+ * leaving the export unmountable. Export it with the server's
+ * global I/O modes instead.
+ */
+ if (!nfsd_nl_iomode_ok) {
+ if (!warned) {
+ xlog(L_WARNING, "%s: kernel does not support riomode= "
+ "or wiomode=, ignoring", ep->e_path);
+ warned = true;
+ }
+ return 0;
+ }
+
+ if (ep->e_riomode != NFSD_IO_UNSET &&
+ nla_put_u32(msg, NFSD_A_SVC_EXPORT_READ_IOMODE,
+ ep->e_riomode) < 0)
+ return -1;
+
+ if (ep->e_wiomode != NFSD_IO_UNSET &&
+ nla_put_u32(msg, NFSD_A_SVC_EXPORT_WRITE_IOMODE,
+ ep->e_wiomode) < 0)
+ return -1;
+
+ return 0;
+}
+
/*
* Add one svc_export response. @ea must be the attributes computed by
* export_attrs_build() for (@path, @exp), and is ignored when @exp is NULL.
@@ -1704,6 +1743,9 @@ static int nfsd_nl_add_export(struct nl_msg *msg, char *domain, char *path,
if (nfsd_nl_add_xprtsec(msg, exp))
goto nla_failure;
+
+ if (nfsd_nl_add_iomode(msg, exp))
+ goto nla_failure;
}
nla_nest_end(msg, nest);
@@ -3261,6 +3303,20 @@ static void cache_sunrpc_nl_process(void)
cache_nl_process_unix_gid();
}
+/* The kernel takes per-export I/O modes only over netlink */
+static void warn_iomode_unsupported(struct exportent *exp)
+{
+ static bool warned;
+
+ if (warned || (exp->e_riomode == NFSD_IO_UNSET &&
+ exp->e_wiomode == NFSD_IO_UNSET))
+ return;
+
+ xlog(L_WARNING, "%s: riomode= and wiomode= need the netlink "
+ "export interface, ignoring", exp->e_path);
+ warned = true;
+}
+
static int dump_to_cache(int f, char *buf, int blen, char *domain,
char *path, struct exportent *exp, int ttl)
{
@@ -3293,6 +3349,7 @@ static int dump_to_cache(int f, char *buf, int blen, char *domain,
qword_addhex(&bp, &blen, ea.uuid, 16);
}
write_xprtsec(&bp, &blen, exp);
+ warn_iomode_unsupported(exp);
xlog(D_AUTH, "granted access to %s for %s",
path, *domain == '$' ? domain+1 : domain);
} else {
diff --git a/support/export/export_policy.c b/support/export/export_policy.c
index a0170cb87f91..797a8459e3c0 100644
--- a/support/export/export_policy.c
+++ b/support/export/export_policy.c
@@ -244,6 +244,28 @@ int nfsd_export_policy_query(struct nfsd_export_policy *UNUSED(pol))
#endif /* HAVE_GENL_POLICY_DUMP */
+/**
+ * nfsd_export_iomode_supported - does the kernel take per-export I/O modes?
+ *
+ * A kernel without them refuses any svc_export entry that carries one.
+ *
+ * Returns 1 if it accepts riomode/wiomode, 0 if not or if that cannot be
+ * found out, or -1 if the nfsd netlink family is not registered, as when
+ * nfsd is not loaded.
+ */
+int nfsd_export_iomode_supported(void)
+{
+ struct nfsd_export_policy pol;
+ int ret;
+
+ ret = nfsd_export_policy_query(&pol);
+ if (ret == -ENOENT)
+ return -1;
+ if (ret < 0)
+ return 0;
+ return pol.max_attr >= NFSD_A_SVC_EXPORT_WRITE_IOMODE;
+}
+
/**
* nfsd_nl_load_export_features - learn the kernel's export flags over netlink
*
diff --git a/support/export/v4root.c b/support/export/v4root.c
index 38286b91ede0..4075b9ef2fef 100644
--- a/support/export/v4root.c
+++ b/support/export/v4root.c
@@ -47,6 +47,8 @@ static nfs_export pseudo_root = {
.e_fsid = 0,
.e_mountpoint = NULL,
.e_ttl = 0,
+ .e_riomode = NFSD_IO_UNSET,
+ .e_wiomode = NFSD_IO_UNSET,
},
.m_exported = 0,
.m_xtabent = 1,
diff --git a/support/include/exportfs.h b/support/include/exportfs.h
index a7c145543075..9096db37a6ee 100644
--- a/support/include/exportfs.h
+++ b/support/include/exportfs.h
@@ -177,6 +177,7 @@ struct nfsd_export_policy {
struct nfsd_export_policy *get_export_features(void);
void set_export_features(const struct nfsd_export_policy *pol);
int nfsd_export_policy_query(struct nfsd_export_policy *pol);
+int nfsd_export_iomode_supported(void);
void nfsd_nl_load_export_features(void);
void fix_pseudoflavor_flags(struct exportent *ep);
diff --git a/support/include/nfsd_netlink.h b/support/include/nfsd_netlink.h
index dcd2126d848b..6628ed7c0922 100644
--- a/support/include/nfsd_netlink.h
+++ b/support/include/nfsd_netlink.h
@@ -59,6 +59,16 @@ enum nfsd_rpcbind_flags {
NFSD_RPCBIND_FLAGS_NO_UDP = 1,
};
+/*
+ * NFS READ/WRITE I/O mode. These values match the global io_cache_read and
+ * io_cache_write debugfs settings.
+ */
+enum nfsd_io_mode {
+ NFSD_IO_MODE_BUFFERED,
+ NFSD_IO_MODE_DONTCACHE,
+ NFSD_IO_MODE_DIRECT,
+};
+
enum {
NFSD_A_CACHE_NOTIFY_CACHE_TYPE = 1,
@@ -185,6 +195,8 @@ enum {
NFSD_A_SVC_EXPORT_XPRTSEC,
NFSD_A_SVC_EXPORT_FLAGS,
NFSD_A_SVC_EXPORT_FSID,
+ NFSD_A_SVC_EXPORT_READ_IOMODE,
+ NFSD_A_SVC_EXPORT_WRITE_IOMODE,
__NFSD_A_SVC_EXPORT_MAX,
NFSD_A_SVC_EXPORT_MAX = (__NFSD_A_SVC_EXPORT_MAX - 1)
diff --git a/support/include/nfslib.h b/support/include/nfslib.h
index c8601a156cba..f2be65b55f8c 100644
--- a/support/include/nfslib.h
+++ b/support/include/nfslib.h
@@ -75,6 +75,16 @@ struct xprtsec_entry {
int flags;
};
+/*
+ * Per-export I/O modes for riomode=/wiomode=. Values must match the
+ * kernel's NFSD_IO_* enum (fs/nfsd/nfsd.h). NFSD_IO_UNSET means no
+ * option was given; the export follows the server's global setting.
+ */
+#define NFSD_IO_BUFFERED 0
+#define NFSD_IO_DONTCACHE 1
+#define NFSD_IO_DIRECT 2
+#define NFSD_IO_UNSET (-1)
+
/*
* Data related to a single exports entry as returned by getexportent.
* FIXME: export options should probably be parsed at a later time to
@@ -100,6 +110,8 @@ struct exportent {
unsigned int e_ttl;
char * e_realpath;
int e_reexport;
+ int e_riomode;
+ int e_wiomode;
};
struct rmtabent {
@@ -115,6 +127,7 @@ void setexportent(char *fname, char *type);
struct exportent * getexportent(int);
void secinfo_show(FILE *fp, struct exportent *ep);
void xprtsecinfo_show(FILE *fp, struct exportent *ep);
+const char * iomode_name(int mode);
void putexportent(struct exportent *xep);
void endexportent(void);
struct exportent * mkexportent(char *hname, char *path, char *opts);
diff --git a/support/nfs/exports.c b/support/nfs/exports.c
index 5ccc4ca0582f..94fa50ecefa3 100644
--- a/support/nfs/exports.c
+++ b/support/nfs/exports.c
@@ -107,6 +107,8 @@ static void init_exportent (struct exportent *ee, int fromkernel)
ee->e_uuid = NULL;
ee->e_ttl = default_ttl;
ee->e_reexport = REEXP_NONE;
+ ee->e_riomode = NFSD_IO_UNSET;
+ ee->e_wiomode = NFSD_IO_UNSET;
}
struct exportent *
@@ -313,6 +315,10 @@ putexportent(struct exportent *ep)
if (ep->e_flags & NFSEXP_SIGN_FH)
fprintf(fp, "sign_fh,");
fprintf(fp, "%spnfs,", (ep->e_flags & NFSEXP_PNFS)? "" : "no_");
+ if (ep->e_riomode != NFSD_IO_UNSET)
+ fprintf(fp, "riomode=%s,", iomode_name(ep->e_riomode));
+ if (ep->e_wiomode != NFSD_IO_UNSET)
+ fprintf(fp, "wiomode=%s,", iomode_name(ep->e_wiomode));
if (ep->e_flags & NFSEXP_FSID) {
fprintf(fp, "fsid=%d,", ep->e_fsid);
}
@@ -586,6 +592,45 @@ static unsigned int parse_xprtsec(char *str, struct exportent *ep)
return out;
}
+/*
+ * Parse the value of a riomode= or wiomode= option into @mode.
+ *
+ * A zero return indicates an error.
+ */
+static int parse_iomode(char *str, int *mode)
+{
+ char *flname = efname ? efname : "command line";
+ int flline = efp ? efp->x_line : 0;
+
+ if (!strcmp(str, "buffered"))
+ *mode = NFSD_IO_BUFFERED;
+ else if (!strcmp(str, "dontcache"))
+ *mode = NFSD_IO_DONTCACHE;
+ else if (!strcmp(str, "direct"))
+ *mode = NFSD_IO_DIRECT;
+ else {
+ xlog(L_ERROR, "%s:%d: bad iomode \"%s\"\n",
+ flname, flline, str);
+ return 0;
+ }
+ return 1;
+}
+
+const char *
+iomode_name(int mode)
+{
+ switch (mode) {
+ case NFSD_IO_BUFFERED:
+ return "buffered";
+ case NFSD_IO_DONTCACHE:
+ return "dontcache";
+ case NFSD_IO_DIRECT:
+ return "direct";
+ default:
+ return "unknown";
+ }
+}
+
/* Sets the bits in @mask for the appropriate security flavor flags. */
static void setflags(int mask, unsigned int active, struct exportent *ep)
{
@@ -804,6 +849,12 @@ bad_option:
} else if (strncmp(opt, "xprtsec=", 8) == 0) {
if (!parse_xprtsec(opt + 8, ep))
goto bad_option;
+ } else if (strncmp(opt, "riomode=", 8) == 0) {
+ if (!parse_iomode(opt + 8, &ep->e_riomode))
+ goto bad_option;
+ } else if (strncmp(opt, "wiomode=", 8) == 0) {
+ if (!parse_iomode(opt + 8, &ep->e_wiomode))
+ goto bad_option;
} else if (strncmp(opt, "reexport=", 9) == 0) {
char *strategy = strchr(opt, '=');
--
2.56.0
^ permalink raw reply related [flat|nested] 5+ messages in thread
* [PATCH nfs-utils 3/4] exportfs: Display and document riomode= and wiomode=
2026-10-27 16:50 [PATCH nfs-utils 0/4] exports: add support for new riomode= and wiomode= export options Jeff Layton
2026-10-27 16:50 ` [PATCH nfs-utils 1/4] exportfs/mountd/exportd: track the svc_export policy highest attribute Jeff Layton
2026-10-27 16:50 ` [PATCH nfs-utils 2/4] exports: Add riomode= and wiomode= export options Jeff Layton
@ 2026-10-27 16:50 ` Jeff Layton
2026-10-27 16:50 ` [PATCH nfs-utils 4/4] nfsd-iomode-advisor: recommend riomode= and wiomode= for an export Jeff Layton
3 siblings, 0 replies; 5+ messages in thread
From: Jeff Layton @ 2026-10-27 16:50 UTC (permalink / raw)
To: Steve Dickson, Chuck Lever, NeilBrown, Olga Kornievskaia, Dai Ngo,
Tom Talpey, Trond Myklebust, Anna Schumaker, Mike Snitzer
Cc: linux-nfs, Jeff Layton
Show explicitly-set I/O modes in "exportfs -v" output and document the
new options in exports(5). Warn when exporting if the running kernel,
or no-netlink, means they will be ignored.
Signed-off-by: Jeff Layton <jlayton@kernel.org>
---
support/include/nfslib.h | 1 +
utils/exportfs/exportfs.c | 34 ++++++++++++++++++++++++++++++++++
utils/exportfs/exports.man | 22 ++++++++++++++++++++++
3 files changed, 57 insertions(+)
diff --git a/support/include/nfslib.h b/support/include/nfslib.h
index f2be65b55f8c..8b515eb1fd5e 100644
--- a/support/include/nfslib.h
+++ b/support/include/nfslib.h
@@ -79,6 +79,7 @@ struct xprtsec_entry {
* Per-export I/O modes for riomode=/wiomode=. Values must match the
* kernel's NFSD_IO_* enum (fs/nfsd/nfsd.h). NFSD_IO_UNSET means no
* option was given; the export follows the server's global setting.
+ * It matches the kernel's NFSD_IO_DEFAULT value.
*/
#define NFSD_IO_BUFFERED 0
#define NFSD_IO_DONTCACHE 1
diff --git a/utils/exportfs/exportfs.c b/utils/exportfs/exportfs.c
index ecf66fd07fd1..6061b2f03d7f 100644
--- a/utils/exportfs/exportfs.c
+++ b/utils/exportfs/exportfs.c
@@ -614,6 +614,32 @@ try_proc:
return 1;
}
+/*
+ * mountd passes riomode= and wiomode= to the kernel only over netlink, and
+ * only to a kernel that takes them. @test_result is from can_test().
+ */
+static void
+validate_iomode(nfs_export *exp, const char *path, int test_result)
+{
+ static int supported = -1;
+
+ if (exp->m_export.e_riomode == NFSD_IO_UNSET &&
+ exp->m_export.e_wiomode == NFSD_IO_UNSET)
+ return;
+
+ if (supported < 0)
+ supported = test_result == 2 ?
+ nfsd_export_iomode_supported() : 0;
+ /* nfsd is not loaded, so there is no telling yet */
+ if (supported < 0)
+ return;
+
+ if (!supported)
+ xlog(L_WARNING, "%s: ignoring riomode= and wiomode=: %s", path,
+ no_netlink ? "no-netlink is set" :
+ "not supported by the running kernel");
+}
+
static void
validate_export(nfs_export *exp)
{
@@ -642,6 +668,8 @@ validate_export(nfs_export *exp)
if (!test_result)
return;
+ validate_iomode(exp, path, test_result);
+
/*
* When netlink is available, skip the export_test() probe.
* mountd/exportd will validate exports at cache-fill time.
@@ -838,6 +866,12 @@ dump(int verbose, int export_format)
c = dumpopt(c, "no_acl");
if (ep->e_flags & NFSEXP_PNFS)
c = dumpopt(c, "pnfs");
+ if (ep->e_riomode != NFSD_IO_UNSET)
+ c = dumpopt(c, "riomode=%s",
+ iomode_name(ep->e_riomode));
+ if (ep->e_wiomode != NFSD_IO_UNSET)
+ c = dumpopt(c, "wiomode=%s",
+ iomode_name(ep->e_wiomode));
if (ep->e_flags & NFSEXP_FSID)
c = dumpopt(c, "fsid=%d", ep->e_fsid);
if (ep->e_uuid)
diff --git a/utils/exportfs/exports.man b/utils/exportfs/exports.man
index 7f0278fb8d33..2dfb12c6a4db 100644
--- a/utils/exportfs/exports.man
+++ b/utils/exportfs/exports.man
@@ -477,6 +477,28 @@ devices. The default can be explicitly requested with the
.I no_pnfs
option.
+.TP
+.IR riomode= buffered|dontcache|direct
+This option selects the I/O mode the server uses for NFS READ requests
+to this export:
+.I buffered
+uses the page cache,
+.I dontcache
+uses the page cache but drops the data behind on completion, and
+.I direct
+uses direct I/O.
+If the option is not specified, the export follows the server's global
+setting, which defaults to
+.IR buffered .
+If the kernel does not support per-export I/O modes, the option is
+ignored and a warning is logged.
+
+.TP
+.IR wiomode= buffered|dontcache|direct
+This option selects the I/O mode the server uses for NFS WRITE requests
+to this export, with the same values and default as
+.IR riomode= .
+
.TP
.IR security_label
With this option set, clients using NFSv4.2 or higher will be able to
--
2.56.0
^ permalink raw reply related [flat|nested] 5+ messages in thread
* [PATCH nfs-utils 4/4] nfsd-iomode-advisor: recommend riomode= and wiomode= for an export
2026-10-27 16:50 [PATCH nfs-utils 0/4] exports: add support for new riomode= and wiomode= export options Jeff Layton
` (2 preceding siblings ...)
2026-10-27 16:50 ` [PATCH nfs-utils 3/4] exportfs: Display and document riomode= and wiomode= Jeff Layton
@ 2026-10-27 16:50 ` Jeff Layton
3 siblings, 0 replies; 5+ messages in thread
From: Jeff Layton @ 2026-10-27 16:50 UTC (permalink / raw)
To: Steve Dickson, Chuck Lever, NeilBrown, Olga Kornievskaia, Dai Ngo,
Tom Talpey, Trond Myklebust, Anna Schumaker, Mike Snitzer
Cc: linux-nfs, Jeff Layton
Admins cannot easily tell which NFSD IO mode suits an export. Add a
tool that samples metrics while clients use the export, and recommends
buffered, dontcache, or direct for READ and WRITE, with reasons.
Metrics:
- miss ratio: export_stats io_read_storage / io_read. Without
io_read_storage: block device read bytes / io_read of its exports.
- reclaim vs. the export's storage traffic, direct reclaim share,
refaults, memory PSI. The tool subtracts cgroups at memory.high or
memory.max (not system memory pressure).
- CPU time of the busiest kswapd or kcompactd thread
- Dirty and Writeback vs. the background writeback threshold, sampled
each second, from /sys/kernel/debug/bdi or /proc/meminfo
- free memory vs. zone high watermarks, to find a page cache that is
not full yet
- average READ (incl. READ_PLUS) and WRITE size from /proc/net/rpc/nfsd
- optional cachestat(2) of the export's largest files
Thresholds are ratios, or relative to export traffic, kernel limits,
or storage, so they do not change with server size.
- large-memory servers with slow storage never reclaim a large
fraction per minute
- kswapd CPU sums scale with NUMA nodes
- stall counts scale with threads
- large servers often set vm.dirty_background_bytes
Mode limits:
- No mode that NFSD silently falls back from. The tool probes with
preadv2(RWF_DONTCACHE) and statx(STATX_DIOALIGN).
- No wiomode=direct when the block device needs more than the 4-byte
memory alignment of TCP WRITE data (e.g. device mapper, MD).
- No direct IO on rotational storage.
- Buffered IO for WRITEs smaller than optimal_io_size (e.g. parity
RAID stripe width).
Caveats:
- Miss ratio is valid only with buffered IO. Otherwise, or if the mode
is not readable, the tool reports "unknown".
- io_read_storage counts only reads that NFSD threads submit. The tool
ignores it on filesystems that read from their own threads (FUSE,
ZFS), and flags it when the block device reads much more.
- export_stats counters persist across cache updates. The tool skips
entries that appear with older counters or leave the cache during
the interval, and flags the result incomplete.
- export_stats lists an export only after first client use. The tool
also accepts an etab export and reads riomode=/wiomode= from etab.
Signed-off-by: Jeff Layton <jlayton@kernel.org>
---
configure.ac | 1 +
tools/Makefile.am | 2 +-
tools/nfsd-iomode-advisor/Makefile.am | 14 +
tools/nfsd-iomode-advisor/nfsd-iomode-advisor.man | 232 ++++
tools/nfsd-iomode-advisor/nfsd-iomode-advisor.py | 1198 +++++++++++++++++++++
5 files changed, 1446 insertions(+), 1 deletion(-)
diff --git a/configure.ac b/configure.ac
index ff752b8bd17f..277cbc2bd109 100644
--- a/configure.ac
+++ b/configure.ac
@@ -779,6 +779,7 @@ AC_CONFIG_FILES([
tools/nfsrahead/Makefile
tools/rpcctl/Makefile
tools/nfsdclnts/Makefile
+ tools/nfsd-iomode-advisor/Makefile
tools/nfsconf/Makefile
tools/nfsdclddb/Makefile
utils/Makefile
diff --git a/tools/Makefile.am b/tools/Makefile.am
index 48fd0cdf1f83..817bf34f0ea6 100644
--- a/tools/Makefile.am
+++ b/tools/Makefile.am
@@ -16,6 +16,6 @@ if CONFIG_NFSRAHEAD
OPTDIRS += nfsrahead
endif
-SUBDIRS = locktest rpcdebug nlmtest mountstats nfs-iostat rpcctl nfsdclnts $(OPTDIRS)
+SUBDIRS = locktest rpcdebug nlmtest mountstats nfs-iostat rpcctl nfsdclnts nfsd-iomode-advisor $(OPTDIRS)
MAINTAINERCLEANFILES = Makefile.in
diff --git a/tools/nfsd-iomode-advisor/Makefile.am b/tools/nfsd-iomode-advisor/Makefile.am
new file mode 100644
index 000000000000..fe97e0ac66c8
--- /dev/null
+++ b/tools/nfsd-iomode-advisor/Makefile.am
@@ -0,0 +1,14 @@
+## Process this file with automake to produce Makefile.in
+PYTHON_FILES = nfsd-iomode-advisor.py
+
+man8_MANS = nfsd-iomode-advisor.man
+
+EXTRA_DIST = $(man8_MANS) $(PYTHON_FILES)
+
+all-local: $(PYTHON_FILES)
+
+install-data-hook:
+ $(MKDIR_P) $(DESTDIR)$(sbindir)
+ $(INSTALL) -m 755 nfsd-iomode-advisor.py $(DESTDIR)$(sbindir)/nfsd-iomode-advisor
+
+MAINTAINERCLEANFILES=Makefile.in
diff --git a/tools/nfsd-iomode-advisor/nfsd-iomode-advisor.man b/tools/nfsd-iomode-advisor/nfsd-iomode-advisor.man
new file mode 100644
index 000000000000..cbfb03b468a9
--- /dev/null
+++ b/tools/nfsd-iomode-advisor/nfsd-iomode-advisor.man
@@ -0,0 +1,232 @@
+.\"
+.\" nfsd-iomode-advisor(8)
+.\"
+.TH NFSD-IOMODE-ADVISOR 8 "26 October 2026"
+.SH NAME
+nfsd-iomode-advisor \- recommend NFSD IO modes for an export
+.SH SYNOPSIS
+.B nfsd-iomode-advisor
+.RB [ \-i
+.IR seconds ]
+.RB [ \-s
+.IR N ]
+.RB [ \-j ]
+.I export
+.SH DESCRIPTION
+.B nfsd-iomode-advisor
+measures page cache and memory reclaim metrics on an NFS server.
+It then recommends values for the
+.B riomode=
+and
+.B wiomode=
+export options of
+.IR export .
+See
+.BR exports (5).
+.PP
+Run the command on the NFS server while clients use the export with a
+typical workload.
+Run it while the export uses buffered IO for READ and WRITE.
+With dontcache or direct IO, the page cache metrics are not meaningful,
+and the recommendation is "unknown".
+.PP
+The command collects these metrics at the start and the end of the
+interval, unless noted:
+.IP \(bu 2
+The bytes that NFSD read and wrote for each export, from
+.IR /proc/fs/nfsd/export_stats .
+.IP \(bu
+The bytes that storage supplied for the READs of the export
+.RI ( io_read_storage
+in
+.IR export_stats ,
+on a kernel with
+.BR CONFIG_TASK_IO_ACCOUNTING ).
+Without this value, the command uses the bytes that the block device
+of the export read.
+The command divides this value by the bytes that NFSD read.
+The result is the miss ratio.
+.IP \(bu
+Page reclaim and refault counters from
+.IR /proc/vmstat .
+The command compares the reclaimed memory with the data that the export
+read from storage and wrote.
+Reclaim can go only as fast as storage fills the page cache, so the
+command does not compare it with the memory size.
+It also shows the part of reclaim that allocating tasks did themselves
+(direct reclaim).
+This part increases when kswapd cannot reclaim fast enough.
+.IP \(bu
+Memory pressure from
+.IR /proc/pressure/memory .
+.IP \(bu
+For each cgroup that reaches its
+.B memory.high
+or
+.B memory.max
+limit during the interval, its reclaim, refaults and memory stalls.
+These are not system memory pressure.
+The command does not count them.
+Stalls of different cgroups can overlap, so the remaining memory
+pressure is a lower limit.
+.IP \(bu
+The CPU time of the busiest kswapd or kcompactd kernel thread.
+Each NUMA node has its own threads.
+.IP \(bu
+Each second, Dirty and Writeback memory of the backing device of the
+export, from
+.IR /sys/kernel/debug/bdi/*/stats .
+If the command cannot read these statistics, it uses the values for
+all devices from
+.IR /proc/meminfo .
+The command compares the average with the background writeback
+threshold of the kernel
+.RB ( vm.dirty_background_ratio
+or
+.BR vm.dirty_background_bytes ).
+.IP \(bu
+Free memory and the high watermarks of the memory zones, from
+.IR /proc/zoneinfo .
+If free memory is far above the watermarks, the page cache has not
+filled yet, and the command shows that its recommendation can change.
+.IP \(bu
+The average READ and WRITE size, from
+.IR /proc/net/rpc/nfsd .
+.IP \(bu
+Optionally, the page cache state of files in the export, from
+.BR cachestat (2).
+.PP
+The command also checks if the filesystem of the export supports
+dontcache and direct IO.
+If it does not, NFSD uses buffered IO for these modes, and the command
+does not recommend them.
+Over TCP, NFSD can write with direct IO only if the block device of
+the export accepts memory that is aligned to 4 bytes.
+Device mapper and MD devices, such as LVM volumes, do not.
+For these exports, the command does not recommend
+.BR wiomode=direct .
+.PP
+The command reads the
+.I rotational
+and
+.I optimal_io_size
+attributes of the block device of the export, from
+.IR /sys/block/*/queue .
+It does not recommend direct IO on rotational storage.
+There, the readahead and writeback of the page cache, which make
+fewer and larger requests, are worth more than the cost of page
+reclaim.
+It recommends buffered IO for WRITEs that are smaller than the optimal
+IO size, for example the stripe width of a parity RAID device.
+Only the page cache collects these WRITEs into full stripes.
+On a system with large pages, it also requires WRITEs of at least
+three pages for direct IO.
+NFSD writes the misaligned start and end of a WRITE up to page
+boundaries with buffered IO.
+.PP
+For each recommendation, the command shows the reasons.
+If the data is not sufficient, the recommendation is "unknown".
+.SH OPTIONS
+.TP
+.BI "\-i, \-\-interval " seconds
+Set the measurement interval.
+The default is 60 seconds.
+.TP
+.BI "\-s, \-\-sample\-files " N
+Use
+.BR cachestat (2)
+on the
+.I N
+largest files in the export.
+The command examines at most 10000 files, or 20 times
+.IR N ,
+to find them.
+The default is 0, which disables this check.
+.TP
+.B \-j, \-\-json
+Show the metrics and the recommendation as JSON.
+.SH NOTES
+The recommendations come from heuristic thresholds.
+Use them as a starting point.
+Change the IO mode of one export at a time, and compare the metrics
+before and after the change.
+.PP
+The miss ratio includes the readahead and the filesystem metadata
+that the READs needed.
+A miss ratio clearly larger than 1 shows that readahead reads data that
+clients do not request.
+.PP
+.I io_read_storage
+counts only the storage reads that NFSD threads submit.
+Some filesystems, such as FUSE filesystems and ZFS, read storage from
+their own threads.
+For these, the command uses block device reads, if available.
+If
+.I io_read_storage
+shows almost no storage reads but the block device read a large
+amount, the command shows that the miss ratio can be too low.
+.PP
+The block device miss ratio is approximate.
+Local applications also cause device reads.
+If more than one export is on the same block device, this miss ratio
+is for all of these exports.
+.PP
+The kernel adds an export to
+.I /proc/fs/nfsd/export_stats
+when a client first uses it.
+For an export that no client uses yet, the command reads the export
+options from
+.IR /var/lib/nfs/etab .
+.PP
+The kernel keeps the counters of an export when mountd refreshes it.
+If an export cache entry appears during the interval with counters
+that started earlier, or leaves the cache during the interval, its
+traffic in the interval is not known, and the command does not include
+it.
+.PP
+The average IO sizes are for all exports.
+The command does not check if READs and WRITEs are aligned for direct
+IO.
+The memory metrics are for the full system, not for each NUMA node.
+.PP
+On a system that uses cgroup v1 for the memory controller, the command
+cannot separate the reclaim of cgroups at their memory limit.
+.PP
+The command cannot see the page cache of the clients.
+If the client page cache supplies most reads, the server page cache
+keeps copies of data that the clients already have.
+See
+.BR mountstats (8)
+on the clients.
+.PP
+The command must run as root to read the global IO modes in debugfs
+and to open the files for
+.BR cachestat (2).
+.SH FILES
+.I /proc/fs/nfsd/export_stats
+.br
+.I /proc/net/rpc/nfsd
+.br
+.I /proc/net/rpc/nfsd.export/content
+.br
+.I /var/lib/nfs/etab
+.br
+.I /sys/fs/cgroup/*/memory.events.local
+.br
+.I /sys/kernel/debug/bdi/*/stats
+.br
+.I /sys/block/*/queue/rotational
+.br
+.I /sys/block/*/queue/optimal_io_size
+.br
+.I /proc/zoneinfo
+.br
+.I /sys/kernel/debug/nfsd/io_cache_read
+.br
+.I /sys/kernel/debug/nfsd/io_cache_write
+.SH SEE ALSO
+.BR exports (5),
+.BR exportfs (8),
+.BR nfsiostat (8),
+.BR mountstats (8),
+.BR cachestat (2)
diff --git a/tools/nfsd-iomode-advisor/nfsd-iomode-advisor.py b/tools/nfsd-iomode-advisor/nfsd-iomode-advisor.py
new file mode 100755
index 000000000000..1bc9acf8fb30
--- /dev/null
+++ b/tools/nfsd-iomode-advisor/nfsd-iomode-advisor.py
@@ -0,0 +1,1198 @@
+#!/usr/bin/python3
+# SPDX-License-Identifier: GPL-2.0-or-later
+"""
+Recommend NFSD read and write IO modes (riomode= and wiomode=) for an
+export, based on page cache and memory reclaim metrics sampled while
+clients use the export.
+
+See Documentation/filesystems/nfs/nfsd-io-modes.rst in the kernel tree.
+"""
+
+import argparse
+import ctypes
+import errno
+import json
+import os
+import platform
+import re
+import struct
+import sys
+import time
+
+EXPORT_STATS = '/proc/fs/nfsd/export_stats'
+EXPORT_CONTENT = '/proc/net/rpc/nfsd.export/content'
+ETAB = '/var/lib/nfs/etab'
+NFSD_STATS = '/proc/net/rpc/nfsd'
+DEBUGFS_NFSD = '/sys/kernel/debug/nfsd'
+DEBUGFS_BDI = '/sys/kernel/debug/bdi'
+CGROUP_ROOT = '/sys/fs/cgroup'
+
+IOMODES = ['buffered', 'dontcache', 'direct']
+COUNTERS = ('io_read', 'io_write', 'io_read_storage')
+
+RWF_DONTCACHE = 0x80
+AT_FDCWD = -100
+STATX_DIOALIGN = 0x2000
+STATX_DIO_READ_ALIGN = 0x20000
+
+# Heuristic thresholds. These are starting points, not tuned values.
+# They are ratios, or relative to the traffic of the export, the limits
+# of the kernel, or the storage, so that they do not depend on the size
+# of the server.
+MIN_IO_BYTES = 64 << 20 # less traffic than this is not conclusive
+MISS_LOW = 0.5 # below: page cache supplies most READ data
+MISS_HIGH = 0.8 # above: storage supplies most READ data
+EVICT_MISS = 0.05 # below: no READ data of the export is evicted
+READAHEAD_MISS = 1.05 # above: readahead reads unrequested data
+RECLAIM_TRAFFIC = 0.25 # bytes reclaimed / export storage traffic
+REFAULT_FRAC = 0.1 # refaults / pages reclaimed
+DIRECT_FRAC = 0.1 # direct reclaim / all reclaim
+PSI_SOME_PCT = 1.0
+PSI_FULL_PCT = 1.0
+RECLAIM_CPU_PCT = 10.0 # busiest kswapd or kcompactd, % of one CPU
+DIRTY_BG_FRAC = 0.5 # (Dirty + Writeback) / background threshold
+FREE_HIGH = 4 # above: free memory / high watermarks
+LARGE_IO = 64 << 10 # average READ/WRITE size for direct IO
+LARGE_WRITE_PAGES = 3 # average WRITE size for direct IO, in pages
+MAX_IO_OPT = 16 << 20 # larger optimal_io_size values are not real
+FILE_SCAN = 10000 # files to examine for --sample-files
+XDR_ALIGN = 4 # alignment of WRITE data received over TCP
+
+# Filesystems that read storage from threads other than the NFSD thread.
+# io_read_storage does not count these reads.
+UNCHARGED_FS = ('fuse', 'fuseblk', 'zfs')
+
+PAGE_SIZE = os.sysconf('SC_PAGE_SIZE')
+
+# Indexes of READ (and NFSv4.2 READ_PLUS) and WRITE in the
+# /proc/net/rpc/nfsd procN lines
+PROC_READ_WRITE = {'proc2': ((6,), (8,)), 'proc3': ((6,), (7,)),
+ 'proc4ops': ((25, 68), (38,))}
+
+_octal_escape = re.compile(r'\\([0-7]{3})')
+
+
+def unescape(s):
+ return _octal_escape.sub(lambda m: chr(int(m.group(1), 8)), s)
+
+
+def read_file(path):
+ try:
+ with open(path) as f:
+ return f.read()
+ except OSError:
+ return None
+
+
+def read_export_stats():
+ """Return {(path, domain): {'start': start_time, counter: value}}."""
+ text = read_file(EXPORT_STATS)
+ if text is None:
+ sys.exit('cannot read %s: is nfsd running?' % EXPORT_STATS)
+ stats = {}
+ key = None
+ for line in text.splitlines():
+ if not line or line.startswith('#'):
+ continue
+ if not line.startswith('\t'):
+ fields = line.split('\t')
+ key = (unescape(fields[0]), unescape(fields[1]))
+ stats[key] = {'start': int(fields[2]), 'io_read': 0, 'io_write': 0}
+ elif key is not None:
+ name, _, val = line.strip().partition(':')
+ stats[key][name] = int(val)
+ return stats
+
+
+def read_etab():
+ """Return {path: [options of each client]} from the exportfs table.
+
+ The kernel adds an export to export_stats only when a client uses it.
+ """
+ exports = {}
+ for line in (read_file(ETAB) or '').splitlines():
+ fields = line.split('\t')
+ if len(fields) < 2:
+ continue
+ opts = fields[1].partition('(')[2].rstrip(')')
+ exports.setdefault(unescape(fields[0]), []).append(opts)
+ return exports
+
+
+def read_export_modes(path, etab):
+ """Return the per-export (riomode, wiomode) of path, None if unset."""
+ opts = []
+ for line in (read_file(EXPORT_CONTENT) or '').splitlines():
+ if line.startswith('#') or '\t' not in line:
+ continue
+ epath, _, rest = line.partition('\t')
+ if unescape(epath) == path:
+ opts.append(rest)
+ if not opts:
+ # No export cache entry yet. Use the settings that mountd sends.
+ opts = etab.get(path, [])
+ rmode = wmode = None
+ for o in opts:
+ m = re.search(r'riomode=(\w+)', o)
+ if m:
+ rmode = m.group(1)
+ m = re.search(r'wiomode=(\w+)', o)
+ if m:
+ wmode = m.group(1)
+ return rmode, wmode
+
+
+def read_global_mode(name):
+ val = read_file(os.path.join(DEBUGFS_NFSD, name))
+ if val is None:
+ return None
+ try:
+ return IOMODES[int(val)]
+ except (ValueError, IndexError):
+ return None
+
+
+def read_nfsd_ops():
+ """Return global NFSD READ/WRITE operation counts and bytes."""
+ text = read_file(NFSD_STATS) or ''
+ out = {'reads': 0, 'writes': 0, 'read_bytes': 0, 'write_bytes': 0}
+ for line in text.splitlines():
+ fields = line.split()
+ if not fields:
+ continue
+ if fields[0] == 'io':
+ out['read_bytes'] = int(fields[1])
+ out['write_bytes'] = int(fields[2])
+ elif fields[0] in PROC_READ_WRITE:
+ counts = [int(x) for x in fields[2:]]
+ rd, wr = PROC_READ_WRITE[fields[0]]
+ out['reads'] += sum(counts[i] for i in rd if i < len(counts))
+ out['writes'] += sum(counts[i] for i in wr if i < len(counts))
+ return out
+
+
+def read_kv(path, sep=None):
+ out = {}
+ for line in (read_file(path) or '').splitlines():
+ fields = line.replace(':', ' ').split() if sep is None else line.split(sep)
+ if len(fields) >= 2:
+ try:
+ out[fields[0]] = int(fields[1])
+ except ValueError:
+ pass
+ return out
+
+
+def read_psi(path='/proc/pressure/memory'):
+ """Return {'some': total_usec, 'full': total_usec}."""
+ out = {}
+ for line in (read_file(path) or '').splitlines():
+ fields = line.split()
+ for f in fields[1:]:
+ if f.startswith('total='):
+ out[fields[0]] = int(f[6:])
+ return out
+
+
+def read_int(path):
+ try:
+ return int(read_file(path))
+ except (TypeError, ValueError):
+ return None
+
+
+def read_reclaim_threads():
+ """Return {pid: (name, CPU ticks)} of the kswapd and kcompactd threads.
+
+ There is one of each for each NUMA node.
+ """
+ out = {}
+ for pid in os.listdir('/proc'):
+ if not pid.isdigit():
+ continue
+ comm = read_file('/proc/%s/comm' % pid)
+ if not comm or not comm.startswith(('kswapd', 'kcompactd')):
+ continue
+ stat = read_file('/proc/%s/stat' % pid)
+ if stat:
+ fields = stat.rpartition(')')[2].split()
+ out[pid] = (comm.strip(), int(fields[11]) + int(fields[12]))
+ return out
+
+
+def read_free_memory():
+ """Return (free pages, sum of the high watermarks) of all zones.
+
+ kswapd reclaims until free memory reaches the high watermarks. Free
+ memory far above them shows that the page cache has not filled yet.
+ """
+ free = high = 0
+ for line in (read_file('/proc/zoneinfo') or '').splitlines():
+ fields = line.split()
+ if fields[:2] == ['pages', 'free']:
+ free += int(fields[2])
+ elif fields[:1] == ['high'] and len(fields) == 2:
+ high += int(fields[1])
+ return free, high
+
+
+def read_memcg_limits():
+ """Return {cgroup: (limit events, pgsteal_direct, refaults, psi)}.
+
+ A cgroup at its memory.high or memory.max reclaims its own pages. That
+ reclaim adds to pgsteal_direct and workingset_refault_file in
+ /proc/vmstat, and its tasks stall in /proc/pressure/memory, but it is
+ not system memory pressure. memory.stat and memory.pressure values
+ include descendants. memory.events.local counts only limits of the
+ cgroup itself.
+ """
+ out = {}
+ stack = [CGROUP_ROOT]
+ while stack:
+ d = stack.pop()
+ try:
+ entries = list(os.scandir(d))
+ except OSError:
+ continue
+ for e in entries:
+ if e.is_dir(follow_symlinks=False):
+ stack.append(e.path)
+ if d == CGROUP_ROOT:
+ continue
+ ev = read_kv(os.path.join(d, 'memory.events.local'))
+ if not ev:
+ continue
+ st = read_kv(os.path.join(d, 'memory.stat'), sep=' ')
+ out[d] = (ev.get('high', 0) + ev.get('max', 0),
+ st.get('pgsteal_direct', 0),
+ st.get('workingset_refault_file', 0),
+ read_psi(os.path.join(d, 'memory.pressure')))
+ return out
+
+
+def memcg_limit_delta(a, b):
+ """Return the reclaim and stalls of cgroups at their memory limit.
+
+ Count each cgroup that reached its limit during the interval, unless
+ an ancestor did too: the ancestor's statistics already include it.
+ Reclaim adds up across cgroups. Stall time does not, because the
+ stalls of different cgroups can overlap, so return the largest.
+ """
+ hit = sorted(d for d, v in b.items() if d in a and v[0] > a[d][0])
+ top = [d for d in hit
+ if not any(d.startswith(p + '/') for p in hit)]
+ out = {'reclaimed': sum(max(b[d][1] - a[d][1], 0) for d in top),
+ 'refault': sum(max(b[d][2] - a[d][2], 0) for d in top),
+ 'cgroups': [os.path.relpath(d, CGROUP_ROOT) for d in top]}
+ for kind in ('some', 'full'):
+ out['psi_' + kind] = max([b[d][3].get(kind, 0) -
+ a[d][3].get(kind, 0) for d in top] or [0])
+ return out
+
+
+def find_mount(path, st_dev):
+ """Return (fstype, source) of the filesystem that holds path."""
+ devstr = '%d:%d' % (os.major(st_dev), os.minor(st_dev))
+ best = None
+ for line in (read_file('/proc/self/mountinfo') or '').splitlines():
+ pre, _, post = line.partition(' - ')
+ pre, post = pre.split(), post.split()
+ fs = (post[0], unescape(post[1]))
+ if pre[2] == devstr:
+ return fs
+ # btrfs subvolumes do not match the device in mountinfo
+ mnt = unescape(pre[4])
+ if (path == mnt or path.startswith(mnt.rstrip('/') + '/')) and \
+ (best is None or len(mnt) >= len(best[0])):
+ best = (mnt, fs)
+ return best[1] if best else (None, None)
+
+
+def find_block_stat(st_dev, source):
+ """Return the sysfs stat file of the block device of a filesystem."""
+ path = '/sys/dev/block/%d:%d/stat' % (os.major(st_dev), os.minor(st_dev))
+ if os.path.exists(path):
+ return path
+ # btrfs and others report an anonymous st_dev. Use the mount source.
+ if source and source.startswith('/dev/'):
+ try:
+ rdev = os.stat(source).st_rdev
+ except OSError:
+ return None
+ path = '/sys/dev/block/%d:%d/stat' % (os.major(rdev), os.minor(rdev))
+ if os.path.exists(path):
+ return path
+ return None
+
+
+def find_queue(block_stat):
+ """Return the sysfs queue directory of the block device of block_stat.
+
+ A partition has no queue directory. Use the queue of its disk.
+ """
+ if block_stat is None:
+ return None
+ d = os.path.realpath(os.path.dirname(block_stat))
+ for q in (os.path.join(d, 'queue'), os.path.join(d, '..', 'queue')):
+ if os.path.isdir(q):
+ return os.path.realpath(q)
+ return None
+
+
+def read_block(path):
+ """Return (read_bytes, write_bytes) of a block device."""
+ if path is None:
+ return None
+ fields = (read_file(path) or '').split()
+ if len(fields) < 7:
+ return None
+ return int(fields[2]) * 512, int(fields[6]) * 512
+
+
+def find_bdi(st_dev, fstype, source):
+ """Return (name, stats path) of the backing device of a filesystem.
+
+ The dirty data of a filesystem is accounted to the backing device
+ (bdi) of its superblock. A filesystem on a whole disk or a device
+ mapper device uses the bdi of that device. A filesystem on a
+ partition uses the bdi of the disk. btrfs and filesystems without
+ a block device (NFS, FUSE) have their own bdi.
+ """
+ dev = '%d:%d' % (os.major(st_dev), os.minor(st_dev))
+ names = []
+ if fstype == 'btrfs' and source and source.startswith('/dev/'):
+ part = os.path.basename(os.path.realpath(source))
+ for d in os.listdir('/sys/fs/btrfs') \
+ if os.path.isdir('/sys/fs/btrfs') else []:
+ if os.path.exists(os.path.join('/sys/fs/btrfs', d, 'devices',
+ part)):
+ try:
+ names.append(os.path.basename(os.readlink(
+ os.path.join('/sys/fs/btrfs', d, 'bdi'))))
+ except OSError:
+ pass
+ sysdev = '/sys/dev/block/' + dev
+ if os.path.exists(os.path.join(sysdev, 'partition')):
+ parent = read_file(os.path.join(os.path.realpath(sysdev), '..',
+ 'dev'))
+ if parent:
+ names.append(parent.strip())
+ names.append(dev)
+ for n in names:
+ path = os.path.join(DEBUGFS_BDI, n, 'stats')
+ if os.path.exists(path):
+ return n, path
+ return None, None
+
+
+def read_dirty(ctx):
+ """Return (Dirty + Writeback, background writeback threshold) in kB.
+
+ The dirty data is that of the export's bdi if possible. The threshold
+ is that of the system, from vm.dirty_background_ratio or
+ vm.dirty_background_bytes. Servers with much memory often set the
+ second, so the threshold is not a fixed fraction of memory.
+ """
+ if ctx['bdi_stats']:
+ kv = read_kv(ctx['bdi_stats'])
+ if 'BdiReclaimable' in kv:
+ return (kv['BdiReclaimable'] + kv.get('BdiWriteback', 0),
+ kv.get('BackgroundThresh', 0))
+ meminfo = read_kv('/proc/meminfo')
+ vmstat = read_kv('/proc/vmstat', sep=' ')
+ return (meminfo.get('Dirty', 0) + meminfo.get('Writeback', 0),
+ vmstat.get('nr_dirty_background_threshold', 0) *
+ PAGE_SIZE // 1024)
+
+
+class Cachestat:
+ """Wrapper for cachestat(2), available in Linux 6.5 and later."""
+
+ class Range(ctypes.Structure):
+ _fields_ = [('off', ctypes.c_uint64), ('len', ctypes.c_uint64)]
+
+ FIELDS = ('nr_cache', 'nr_dirty', 'nr_writeback',
+ 'nr_evicted', 'nr_recently_evicted')
+
+ class Stat(ctypes.Structure):
+ _fields_ = [(n, ctypes.c_uint64) for n in
+ ('nr_cache', 'nr_dirty', 'nr_writeback',
+ 'nr_evicted', 'nr_recently_evicted')]
+
+ def __init__(self):
+ self.libc = ctypes.CDLL(None, use_errno=True)
+ self.nr = 561 if platform.machine().startswith('alpha') else 451
+
+ def __call__(self, path):
+ try:
+ fd = os.open(path, os.O_RDONLY | os.O_NOATIME)
+ except OSError:
+ try:
+ fd = os.open(path, os.O_RDONLY)
+ except OSError:
+ return None
+ try:
+ rng = self.Range(0, 0)
+ cs = self.Stat()
+ if self.libc.syscall(self.nr, fd, ctypes.byref(rng),
+ ctypes.byref(cs), 0) != 0:
+ return None
+ return {n: getattr(cs, n) for n in self.FIELDS}
+ finally:
+ os.close(fd)
+
+
+def probe_dontcache(files):
+ """Return True if the filesystem accepts RWF_DONTCACHE, None if unknown.
+
+ NFSD silently uses buffered IO instead of dontcache when the
+ filesystem does not set FOP_DONTCACHE.
+ """
+ for path in files:
+ try:
+ fd = os.open(path, os.O_RDONLY)
+ except OSError:
+ continue
+ try:
+ os.preadv(fd, [bytearray(1)], 0, RWF_DONTCACHE)
+ return True
+ except OSError as e:
+ if e.errno in (errno.EOPNOTSUPP, errno.EINVAL):
+ return False
+ finally:
+ os.close(fd)
+ return None
+
+
+def probe_direct(files):
+ """Return (read_ok, write_ok, mem_align), Nones if unknown.
+
+ NFSD uses the STATX_DIOALIGN and STATX_DIO_READ_ALIGN values of the
+ file. Without them, NFSD does not use direct IO. A direct READ or
+ WRITE then uses dontcache if possible, else buffered IO.
+ """
+ try:
+ statx = ctypes.CDLL(None, use_errno=True).statx
+ except AttributeError:
+ return None, None, None
+ for path in files:
+ buf = ctypes.create_string_buffer(0x100)
+ if statx(AT_FDCWD, path.encode(), 0,
+ STATX_DIOALIGN | STATX_DIO_READ_ALIGN, buf) != 0:
+ continue
+ mask, = struct.unpack_from('I', buf, 0x00)
+ mem_align, offset_align = struct.unpack_from('II', buf, 0x98)
+ read_align, = struct.unpack_from('I', buf, 0xb4)
+ if not mask & STATX_DIOALIGN:
+ mem_align = offset_align = 0
+ if not mask & STATX_DIO_READ_ALIGN:
+ read_align = offset_align
+ return bool(read_align), bool(mem_align and offset_align), mem_align
+ return None, None, None
+
+
+def rdma_listener():
+ """Return True if NFSD listens for RPC-over-RDMA connections."""
+ ports = read_file('/proc/fs/nfsd/portlist') or ''
+ return any(line.split()[:1] == ['rdma'] for line in ports.splitlines())
+
+
+def pick_files(root, count, budget):
+ """Return the largest regular files under root, on the same filesystem.
+
+ Examine at most budget files.
+ """
+ dev = os.stat(root).st_dev
+ found = []
+ stack = [root]
+ while stack and budget > 0:
+ d = stack.pop()
+ try:
+ entries = list(os.scandir(d))
+ except OSError:
+ continue
+ for e in entries:
+ try:
+ st = e.stat(follow_symlinks=False)
+ except OSError:
+ continue
+ if st.st_dev != dev:
+ continue
+ if e.is_dir(follow_symlinks=False):
+ stack.append(e.path)
+ elif e.is_file(follow_symlinks=False) and st.st_size:
+ found.append((st.st_size, e.path))
+ budget -= 1
+ found.sort(reverse=True)
+ return [p for _, p in found[:count]]
+
+
+def sample(ctx):
+ s = {
+ 'time': time.monotonic(),
+ 'exports': read_export_stats(),
+ 'nfsd': read_nfsd_ops(),
+ 'vmstat': read_kv('/proc/vmstat', sep=' '),
+ 'psi': read_psi(),
+ 'reclaim': read_reclaim_threads(),
+ 'memcg': read_memcg_limits(),
+ 'block': read_block(ctx['block_stat']),
+ }
+ if ctx['files']:
+ cs = [ctx['cachestat'](p) for p in ctx['files']]
+ s['cachestat'] = [c for c in cs if c is not None]
+ return s
+
+
+def measure(ctx, interval):
+ """Return the samples at the start and end of the interval, and the
+ dirty data and threshold of each second.
+
+ The amount of dirty data changes quickly. Two values are not enough.
+ """
+ a = sample(ctx)
+ dirty = [read_dirty(ctx)]
+ end = a['time'] + interval
+ while True:
+ left = end - time.monotonic()
+ if left <= 0:
+ break
+ time.sleep(min(left, 1.0))
+ dirty.append(read_dirty(ctx))
+ return a, sample(ctx), dirty
+
+
+def export_delta(a, b, paths):
+ """Sum the counter deltas of all entries for paths.
+
+ The kernel keeps the counters of an export across export cache
+ updates, but does not list an entry while it is out of the cache.
+ An entry that is new in b, or whose start time changed, counts only
+ if its counters started during the interval. Otherwise its traffic
+ in the interval is not known. The start time is CLOCK_MONOTONIC
+ seconds, the clock of time.monotonic().
+
+ An entry that is in a but not in b left the cache during the
+ interval, for example by a cache flush. Its traffic after a is not
+ known either.
+
+ Returns (deltas, incomplete). A counter that an entry does not
+ report is None in deltas.
+ """
+ out = dict.fromkeys(COUNTERS, 0)
+ incomplete = any(key[0] in paths and key not in b['exports']
+ for key in a['exports'])
+ t0 = int(a['time'])
+ for key, nv in b['exports'].items():
+ if key[0] not in paths:
+ continue
+ ov = a['exports'].get(key)
+ if ov is not None and ov['start'] == nv['start']:
+ base = ov
+ elif nv['start'] >= t0:
+ base = {}
+ else:
+ incomplete = True
+ continue
+ for c in COUNTERS:
+ if out[c] is None:
+ continue
+ if c not in nv or (base and c not in base):
+ out[c] = None
+ else:
+ out[c] += nv[c] - base.get(c, 0)
+ return out, incomplete
+
+
+def analyze(ctx, a, b, dirty):
+ dt = b['time'] - a['time']
+ va, vb = a['vmstat'], b['vmstat']
+
+ def dv(name):
+ return vb.get(name, 0) - va.get(name, 0)
+
+ m = {'interval': dt}
+ d, m['incomplete'] = export_delta(a, b, {ctx['path']})
+ m['io_read'], m['io_write'] = d['io_read'], d['io_write']
+ fs, fs_incomplete = export_delta(a, b, ctx['fs_exports'])
+ m['fs_io_read'] = fs['io_read']
+
+ # io_read_storage is exact for the export, if the NFSD thread submits
+ # the storage reads. Block device reads also count other exports and
+ # local applications on the device.
+ m['device_read'] = m['device_miss_ratio'] = None
+ if a['block'] and b['block']:
+ m['device_read'] = b['block'][0] - a['block'][0]
+ if fs['io_read']:
+ m['device_miss_ratio'] = m['device_read'] / fs['io_read']
+ storage = None if ctx['uncharged'] else d['io_read_storage']
+ m['storage_read'] = m['miss_ratio'] = m['miss_source'] = None
+ if storage is not None:
+ if d['io_read']:
+ m['storage_read'] = storage
+ m['miss_ratio'] = storage / d['io_read']
+ m['miss_source'] = 'export'
+ elif m['device_miss_ratio'] is not None:
+ m['storage_read'] = m['device_read']
+ m['miss_ratio'] = m['device_miss_ratio']
+ m['miss_source'] = 'device'
+ m['incomplete'] = m['incomplete'] or fs_incomplete
+
+ na, nb = a['nfsd'], b['nfsd']
+ reads = nb['reads'] - na['reads']
+ writes = nb['writes'] - na['writes']
+ m['avg_read'] = (nb['read_bytes'] - na['read_bytes']) // reads if reads else None
+ m['avg_write'] = (nb['write_bytes'] - na['write_bytes']) // writes if writes else None
+
+ # Reclaim by cgroups at their memory limit is not system memory
+ # pressure. Do not count it.
+ lim = memcg_limit_delta(a['memcg'], b['memcg'])
+ m['memcg_reclaimed'] = lim['reclaimed']
+ m['memcg_refault'] = lim['refault']
+ m['memcg_cgroups'] = lim['cgroups']
+ m['reclaimed_direct'] = max(dv('pgsteal_direct') - m['memcg_reclaimed'], 0)
+ m['reclaimed'] = dv('pgsteal_kswapd') + m['reclaimed_direct']
+ m['refault'] = max(dv('workingset_refault_file') - m['memcg_refault'], 0)
+ m['activate'] = dv('workingset_activate_file')
+ m['allocstall'] = sum(vb[k] - va.get(k, 0) for k in vb
+ if k.startswith('allocstall'))
+ m['pgscan_direct'] = dv('pgscan_direct')
+ m['compact_stall'] = dv('compact_stall')
+ m['refault_frac'] = m['refault'] / m['reclaimed'] if m['reclaimed'] else 0
+ # The share of reclaim that allocating tasks do themselves. It
+ # increases when kswapd cannot reclaim fast enough. A count of stalls
+ # increases with the number of threads, and thus with server size.
+ m['direct_frac'] = m['reclaimed_direct'] / m['reclaimed'] \
+ if m['reclaimed'] else None
+
+ # Compare reclaim with the data that the export moves into the page
+ # cache, not with the memory size. Reclaim can go only as fast as
+ # storage fills the page cache, and a server with much memory and
+ # slow storage never reclaims a large fraction of its memory.
+ est_read = m['io_read'] * m['miss_ratio'] \
+ if m['miss_ratio'] is not None else m['io_read']
+ m['traffic'] = est_read + m['io_write']
+ m['reclaim_traffic'] = m['reclaimed'] * PAGE_SIZE / m['traffic'] \
+ if m['traffic'] else None
+
+ # Stalls of tasks in cgroups at their memory limit are not system
+ # memory pressure either. Without them, at least the rest remains.
+ for kind in ('some', 'full'):
+ m['memcg_psi_' + kind] = lim['psi_' + kind] / (dt * 1e4)
+ if kind in a['psi'] and kind in b['psi']:
+ m['psi_' + kind] = max(b['psi'][kind] - a['psi'][kind] -
+ lim['psi_' + kind], 0) / (dt * 1e4)
+ else:
+ m['psi_' + kind] = None
+
+ # Use the busiest thread. The sum of all of them increases with the
+ # number of NUMA nodes, but each node has its own kswapd.
+ ra, rb = a['reclaim'], b['reclaim']
+ ticks, m['reclaim_thread'] = max(((rb[p][1] - ra[p][1], rb[p][0])
+ for p in rb if p in ra),
+ default=(0, None))
+ if not ticks:
+ m['reclaim_thread'] = None
+ m['reclaim_cpu'] = ticks * 100.0 / (os.sysconf('SC_CLK_TCK') * dt)
+
+ m['dirty_kb'] = sum(v for v, _ in dirty) // len(dirty)
+ m['dirty_bg_kb'] = sum(t for _, t in dirty) // len(dirty)
+ m['dirty_bg_frac'] = m['dirty_kb'] / m['dirty_bg_kb'] \
+ if m['dirty_bg_kb'] else 0
+ m['dirty_source'] = ctx['bdi'] or 'all devices'
+
+ # A server with much memory can take many minutes to fill its page
+ # cache. Until then, there is no reclaim. While there is reclaim, free
+ # memory can be far above the watermarks (kswapd reclaims past them,
+ # and a NUMA node can be full while others are not), so reclaim also
+ # shows a full page cache.
+ free, high = read_free_memory()
+ m['free_kb'] = free * PAGE_SIZE // 1024
+ m['cache_full'] = bool(m['reclaimed']) or not high or \
+ free <= FREE_HIGH * high
+ m['fill_minutes'] = free * PAGE_SIZE * dt / (m['traffic'] * 60) \
+ if m['traffic'] else None
+
+ if 'cachestat' in b:
+ csa = {k: sum(c[k] for c in a['cachestat']) for k in Cachestat.FIELDS}
+ csb = {k: sum(c[k] for c in b['cachestat']) for k in Cachestat.FIELDS}
+ m['cachestat'] = {'files': len(b['cachestat']), **csb,
+ 'recently_evicted_delta':
+ csb['nr_recently_evicted'] - csa['nr_recently_evicted']}
+ return m
+
+
+def recommend(ctx, m):
+ """Return (riomode, read_reasons, wiomode, write_reasons)."""
+ pressure = (m['reclaim_traffic'] or 0) > RECLAIM_TRAFFIC or \
+ (m['psi_some'] or 0) > PSI_SOME_PCT
+ costly = (m['psi_full'] or 0) > PSI_FULL_PCT or \
+ m['reclaim_cpu'] > RECLAIM_CPU_PCT or \
+ (pressure and (m['direct_frac'] or 0) > DIRECT_FRAC)
+ dontcache_ok = ctx['dontcache'] is not False
+ fsname = ctx['fstype'] or 'This filesystem'
+
+ def uncached(avg, large_min, why, direct, direct_why=None):
+ large = avg is not None and avg >= large_min
+ if direct is None or ctx['dontcache'] is None:
+ why = why + ['IO mode support was not verified. No readable '
+ 'file was found in the export.']
+ if direct is not False and ctx['rotational']:
+ direct = False
+ direct_why = ('the storage is rotational. Direct IO gives up '
+ 'the readahead and writeback of the page cache, '
+ 'which make fewer and larger requests.')
+ direct_ok = direct is not False
+ if costly and large and direct_ok:
+ return 'direct', why + ['Page reclaim is costly and the average '
+ 'IO size is large.']
+ if costly and large and direct_why:
+ # Direct IO would help, but NFSD cannot use it here
+ why = why + ['Page reclaim is costly and the average IO size '
+ 'is large, but ' + direct_why]
+ if dontcache_ok:
+ return 'dontcache', why
+ # Direct IO helps over buffered IO only if page reclaim is costly
+ if not direct_ok and direct_why:
+ return 'buffered', why + ['%s does not support dontcache.' %
+ fsname]
+ if not direct_ok:
+ return 'buffered', why + ['%s supports neither dontcache nor '
+ 'direct IO. NFSD uses buffered IO for '
+ 'all modes.' % fsname]
+ if not large:
+ return 'buffered', why + ['%s does not support dontcache, and '
+ 'the average IO size is small for '
+ 'direct IO.' % fsname]
+ return 'buffered', why + ['%s does not support dontcache, and page '
+ 'reclaim is not costly enough for direct '
+ 'IO.' % fsname]
+
+ def unfilled():
+ if pressure or m['cache_full']:
+ return []
+ why = ['Free memory is far above the reclaim watermarks, so the '
+ 'page cache has not filled yet.']
+ if ctx['fs_used'] is not None and \
+ ctx['fs_used'] <= m['free_kb'] * 1024:
+ why.append('All data on the filesystem of the export (%s) fits '
+ 'in free memory, so READs of this export alone '
+ 'cannot fill it.' % fmt_bytes(ctx['fs_used']))
+ return why
+ why.append('When it fills, there can be page reclaim, and the '
+ 'recommendation can change.')
+ if m['fill_minutes'] is not None:
+ why.append('At the current storage traffic of the export, free '
+ 'memory fills in about %.0f minutes. Run again after '
+ 'that, or with a longer interval.' % m['fill_minutes'])
+ return why
+
+ def current_mode(opt, knob, mode, src):
+ if src == 'export':
+ return 'The export uses %s=%s.' % (opt, mode)
+ return 'The export has no %s= setting, and the global %s setting ' \
+ 'is %s.' % (opt, knob, mode)
+
+ def eviction():
+ why = []
+ if m.get('cachestat', {}).get('recently_evicted_delta', 0) > 0:
+ why.append('Page reclaim evicts data of this export.')
+ elif pressure and m['refault_frac'] > REFAULT_FRAC and \
+ m['miss_ratio'] > EVICT_MISS:
+ # System-wide refaults matter only if storage supplies some
+ # READ data of this export
+ why.append('Page reclaim evicts file data that is then read '
+ 'again.')
+ if why:
+ why.append('Other exports with read-once data can use dontcache '
+ 'or direct IO to give more page cache to this one.')
+ return why
+
+ readahead = []
+ if m['miss_ratio'] is not None and m['miss_ratio'] > READAHEAD_MISS:
+ readahead = ['Storage read more data than NFSD returned. Readahead '
+ 'reads data that clients do not request, for example '
+ 'with small random READs.']
+
+ # READ
+ rwhy = []
+ if ctx['cur_read'] not in ('buffered', None):
+ rmode = None
+ rwhy.append(current_mode('riomode', 'io_cache_read', ctx['cur_read'],
+ ctx['read_src']) +
+ ' The miss ratio is not meaningful. Set '
+ 'riomode=buffered and run again.')
+ elif m['io_read'] < MIN_IO_BYTES:
+ rmode = None
+ rwhy.append('Not enough READ traffic in the interval.')
+ elif m['miss_ratio'] is None and ctx['uncharged']:
+ rmode = None
+ rwhy.append('%s reads storage from its own threads, which '
+ 'io_read_storage does not count, and has no block '
+ 'device statistics. The miss ratio is not available.' %
+ fsname)
+ elif m['miss_ratio'] is None:
+ rmode = None
+ rwhy.append('No block device statistics for this filesystem. '
+ 'The miss ratio is not available.')
+ elif m['miss_ratio'] < MISS_LOW:
+ rmode = 'buffered'
+ rwhy.append('The page cache supplies most READ data.')
+ if m['miss_source'] == 'export' and m['miss_ratio'] < EVICT_MISS and \
+ (m['device_miss_ratio'] or 0) > MISS_LOW:
+ rwhy.append('But the block device read %s while NFSD read %s. '
+ 'io_read_storage counts only the storage reads that '
+ 'NFSD threads submit. If %s reads storage from other '
+ 'threads, the miss ratio is too low.' %
+ (fmt_bytes(m['device_read']),
+ fmt_bytes(m['fs_io_read']), fsname))
+ rwhy += eviction()
+ elif m['miss_ratio'] < MISS_HIGH:
+ rmode = 'buffered'
+ rwhy.append('The page cache supplies part of the READ data.')
+ rwhy += eviction()
+ elif not pressure and ctx['cur_read'] is None:
+ # Dontcache and direct IO also look like this
+ rmode = None
+ rwhy.append('Storage supplies most READ data, and there is no '
+ 'memory pressure. The current IO mode is not known, so '
+ 'the cause is not clear. Run as root.')
+ rwhy.extend(readahead)
+ elif not pressure:
+ rmode = 'buffered'
+ rwhy.append('Storage supplies most READ data, but there is no '
+ 'memory pressure. Buffered IO costs little.')
+ rwhy.extend(readahead)
+ rwhy.extend(unfilled())
+ else:
+ # The page cache supplies less than 1 - MISS_HIGH of the READ data.
+ # Whether or not the export re-reads evicted data, keeping it in
+ # the page cache costs reclaim for little benefit.
+ why = ['Storage supplies most READ data, and there is memory '
+ 'pressure.'] + readahead
+ if m['refault_frac'] > REFAULT_FRAC:
+ why.append('Other data is evicted and then read again. Less '
+ 'page cache use by this export helps that data.')
+ rmode, rwhy = uncached(m['avg_read'], LARGE_IO, why,
+ ctx['direct_read'])
+
+ # WRITE
+ wwhy = []
+ if ctx['cur_write'] not in ('buffered', None):
+ wmode = None
+ wwhy.append(current_mode('wiomode', 'io_cache_write',
+ ctx['cur_write'], ctx['write_src']) +
+ ' The memory pressure and dirty data values do not '
+ 'include buffered WRITEs. Set wiomode=buffered and run '
+ 'again.')
+ elif m['io_write'] < MIN_IO_BYTES:
+ wmode = None
+ wwhy.append('Not enough WRITE traffic in the interval.')
+ elif m['avg_write'] is not None and m['avg_write'] < ctx['write_large']:
+ wmode = 'buffered'
+ wwhy.append('The average WRITE size is smaller than %s%s. The page '
+ 'cache collects small WRITEs into larger units.' %
+ (fmt_bytes(ctx['write_large']), ctx['write_large_why']))
+ elif rmode == 'buffered' and m['miss_ratio'] is not None and \
+ m['miss_ratio'] < MISS_LOW:
+ wmode = 'buffered'
+ wwhy.append('READs of this export use the page cache. Buffered '
+ 'WRITEs keep written data there for later READs.')
+ elif not pressure and m['dirty_bg_frac'] < DIRTY_BG_FRAC:
+ wmode = 'buffered'
+ wwhy.append('There is no memory pressure, and little dirty data.')
+ wwhy.extend(unfilled())
+ else:
+ wmode, wwhy = uncached(m['avg_write'], ctx['write_large'],
+ ['WRITEs are large and there is memory '
+ 'pressure or much dirty data.'],
+ ctx['direct_write'], ctx['direct_write_why'])
+
+ return rmode, rwhy, wmode, wwhy
+
+
+def fmt_bytes(n):
+ if n is None:
+ return 'n/a'
+ for unit in ('B', 'KiB', 'MiB', 'GiB', 'TiB'):
+ if abs(n) < 1024 or unit == 'TiB':
+ return '%.1f %s' % (n, unit) if unit != 'B' else '%d B' % n
+ n /= 1024
+
+
+def fmt_pct(v):
+ return 'n/a' if v is None else '%.2f%%' % v
+
+
+def report(ctx, m, rec):
+ rmode, rwhy, wmode, wwhy = rec
+ print('Export: %s' % ctx['path'])
+ print('Filesystem: %s (%s)' % (ctx['fstype'], ctx['source']))
+ support = {True: 'supported', False: 'not supported', None: 'unknown'}
+ print('Dontcache: %s' % support[ctx['dontcache']])
+ if ctx['direct_write_why']:
+ dw = 'not usable over TCP (memory alignment %d bytes)' % \
+ ctx['dio_mem_align']
+ else:
+ dw = support[ctx['direct_write']]
+ print('Direct IO: READ %s, WRITE %s' %
+ (support[ctx['direct_read']], dw))
+ rot = {True: 'rotational', False: 'not rotational', None: 'unknown'}
+ print('Storage: %s, optimal IO size %s' %
+ (rot[ctx['rotational']],
+ fmt_bytes(ctx['io_opt']) if ctx['io_opt'] else 'not reported'))
+ print('Current modes: riomode=%s wiomode=%s' %
+ (ctx['cur_read'] or 'unknown', ctx['cur_write'] or 'unknown'))
+ print('Interval: %.0f s' % m['interval'])
+ print()
+ print('Export traffic: read %s, written %s' %
+ (fmt_bytes(m['io_read']), fmt_bytes(m['io_write'])))
+ if m['miss_source'] == 'device' and len(ctx['fs_exports']) > 1:
+ print('Device traffic: read %s (exports: %s)' %
+ (fmt_bytes(m['fs_io_read']), ', '.join(sorted(ctx['fs_exports']))))
+ source = {'export': 'io_read_storage', 'device': 'block device'}
+ print('Storage reads: %s' % fmt_bytes(m['storage_read']))
+ print('Miss ratio: %s' %
+ ('n/a' if m['miss_ratio'] is None else '%.2f (%s)' %
+ (m['miss_ratio'], source[m['miss_source']])))
+ print('Avg NFSD READ: %s (all exports)' % fmt_bytes(m['avg_read']))
+ print('Avg NFSD WRITE: %s (all exports)' % fmt_bytes(m['avg_write']))
+ print()
+ print('Pages reclaimed: %d (%s%s)' %
+ (m['reclaimed'], fmt_bytes(m['reclaimed'] * PAGE_SIZE),
+ '' if m['reclaim_traffic'] is None else
+ ', %.2f times the storage traffic of the export' %
+ m['reclaim_traffic']))
+ print('File refaults: %d (%.1f%% of reclaimed)' %
+ (m['refault'], m['refault_frac'] * 100))
+ if m['memcg_cgroups']:
+ print('Limit reclaim: %d pages, %d refaults, PSI some %s, '
+ 'not counted (cgroups at memory.high or memory.max: %s)' %
+ (m['memcg_reclaimed'], m['memcg_refault'],
+ fmt_pct(m['memcg_psi_some']), ', '.join(m['memcg_cgroups'])))
+ print('Memory PSI: some %s, full %s' %
+ (fmt_pct(m['psi_some']), fmt_pct(m['psi_full'])))
+ print('Reclaim CPU: %.1f%% of one CPU (busiest of kswapd and '
+ 'kcompactd: %s)' % (m['reclaim_cpu'], m['reclaim_thread'] or 'n/a'))
+ print('Direct reclaim: %d allocstalls, %d pages scanned, %s of '
+ 'reclaim' % (m['allocstall'], m['pgscan_direct'],
+ fmt_pct(None if m['direct_frac'] is None else
+ m['direct_frac'] * 100)))
+ print('Compaction stalls: %d' % m['compact_stall'])
+ print('Dirty+Writeback: %s average, %.0f%% of the background writeback '
+ 'threshold %s (%s)' %
+ (fmt_bytes(m['dirty_kb'] * 1024), m['dirty_bg_frac'] * 100,
+ fmt_bytes(m['dirty_bg_kb'] * 1024),
+ 'device ' + ctx['bdi'] if ctx['bdi'] else 'all devices'))
+ print('Free memory: %s (%s)' %
+ (fmt_bytes(m['free_kb'] * 1024),
+ 'page cache is full' if m['cache_full'] else
+ 'page cache has not filled'))
+ if 'cachestat' in m:
+ cs = m['cachestat']
+ print('Sampled files: %d, %d pages cached, %d recently evicted '
+ '(%+d)' % (cs['files'], cs['nr_cache'],
+ cs['nr_recently_evicted'],
+ cs['recently_evicted_delta']))
+ print()
+ print('Recommended riomode: %s' % (rmode or 'unknown'))
+ for r in rwhy:
+ print(' - ' + r)
+ print('Recommended wiomode: %s' % (wmode or 'unknown'))
+ for r in wwhy:
+ print(' - ' + r)
+ notes = []
+ if ctx['cur_read'] is None or ctx['cur_write'] is None:
+ notes.append('Cannot read the global IO modes in %s. Run as root. '
+ 'The analysis assumes buffered IO.' % DEBUGFS_NFSD)
+ if m['incomplete']:
+ notes.append('An export cache entry appeared during the interval '
+ 'with counters that started earlier, or left the '
+ 'cache. Its traffic is not included. Traffic values '
+ 'can be too low.')
+ if m['miss_source'] == 'export':
+ notes.append('The miss ratio includes readahead and filesystem '
+ 'metadata reads.')
+ if ctx['uncharged'] and m['miss_source'] == 'device':
+ notes.append('%s reads storage from its own threads, so the miss '
+ 'ratio uses block device reads.' % ctx['fstype'])
+ elif m['miss_source'] == 'device' and \
+ ctx['cur_read'] in ('buffered', None):
+ notes.append('The kernel does not report io_read_storage. The miss '
+ 'ratio uses block device reads. Readahead, metadata and '
+ 'local applications also cause device reads. The miss '
+ 'ratio is approximate.')
+ if m['miss_source'] == 'device' and ctx['fstype'] == 'btrfs':
+ notes.append('btrfs can use more than one device. Only %s is '
+ 'measured.' % ctx['source'])
+ if ctx['direct_write_why'] and ctx['rdma']:
+ notes.append('NFSD also listens for RDMA. The WRITE recommendation '
+ 'is for clients that use TCP.')
+ if not ctx['bdi']:
+ notes.append('Cannot read the statistics of the backing device in '
+ '%s. Dirty and Writeback are for all devices.' %
+ DEBUGFS_BDI)
+ if ctx['memcg_v1']:
+ notes.append('The memory controller uses cgroup v1. The reclaim and '
+ 'stalls of cgroups at their memory limit cannot be '
+ 'separated, and count as system memory pressure.')
+ notes.append('Average IO sizes are for all exports. The command does '
+ 'not check if READs and WRITEs are aligned for direct IO. '
+ 'Memory metrics are for the full system, not for each '
+ 'NUMA node.')
+ print()
+ for n in notes:
+ print('Note: ' + n)
+
+
+def main():
+ p = argparse.ArgumentParser(
+ description='Recommend NFSD riomode= and wiomode= settings for an '
+ 'export. Run while clients use the export with a '
+ 'typical workload.')
+ p.add_argument('export', help='path of the export')
+ p.add_argument('-i', '--interval', type=float, default=60,
+ help='measurement interval in seconds (default: 60)')
+ p.add_argument('-s', '--sample-files', type=int, default=0, metavar='N',
+ help='check the page cache state of the N largest files '
+ 'in the export with cachestat(2) (default: 0)')
+ p.add_argument('-j', '--json', action='store_true',
+ help='print the metrics and recommendation as JSON')
+ args = p.parse_args()
+
+ path = os.path.realpath(args.export)
+ try:
+ st = os.stat(path)
+ except OSError as e:
+ sys.exit('%s: %s' % (args.export, e.strerror))
+
+ exports = read_export_stats()
+ etab = read_etab()
+ if not any(k[0] == path for k in exports) and path not in etab:
+ sys.exit('%s is not an export: it is not in %s or %s' %
+ (path, EXPORT_STATS, ETAB))
+
+ fstype, source = find_mount(path, st.st_dev)
+ block_stat = find_block_stat(st.st_dev, source)
+
+ # The exports whose READs the block device statistics also count.
+ # btrfs subvolumes on one device have different st_dev values. Paths
+ # with the same root (bind mounts) share counters, so keep one.
+ fs_exports = {path}
+ roots = {(st.st_dev, st.st_ino)}
+ for epath in {k[0] for k in exports} | set(etab):
+ try:
+ est = os.stat(epath)
+ except OSError:
+ continue
+ root = (est.st_dev, est.st_ino)
+ if root in roots:
+ continue
+ if block_stat is not None:
+ esrc = find_mount(epath, est.st_dev)[1]
+ same = find_block_stat(est.st_dev, esrc) == block_stat
+ else:
+ same = est.st_dev == st.st_dev
+ if same:
+ roots.add(root)
+ fs_exports.add(epath)
+
+ rmode, wmode = read_export_modes(path, etab)
+ ctx = {
+ 'path': path,
+ 'fs_exports': fs_exports,
+ 'fstype': fstype,
+ 'source': source,
+ 'block_stat': block_stat,
+ 'cur_read': rmode or read_global_mode('io_cache_read'),
+ 'cur_write': wmode or read_global_mode('io_cache_write'),
+ 'read_src': 'export' if rmode else 'global',
+ 'write_src': 'export' if wmode else 'global',
+ 'files': [],
+ 'uncharged': (fstype or '').split('.')[0] in UNCHARGED_FS,
+ 'memcg_v1': os.path.exists(os.path.join(CGROUP_ROOT, 'memory',
+ 'memory.stat')),
+ }
+ ctx['bdi'], ctx['bdi_stats'] = find_bdi(st.st_dev, fstype, source)
+ try:
+ sv = os.statvfs(path)
+ ctx['fs_used'] = (sv.f_blocks - sv.f_bfree) * sv.f_frsize
+ except OSError:
+ ctx['fs_used'] = None
+
+ # A WRITE of the size that the storage prefers avoids read-modify-write
+ # on parity RAID. Only the page cache can collect smaller WRITEs into
+ # units of this size.
+ queue = find_queue(block_stat)
+ rot = read_int(os.path.join(queue, 'rotational')) if queue else None
+ ctx['rotational'] = None if rot is None else rot == 1
+ ctx['io_opt'] = (read_int(os.path.join(queue, 'optimal_io_size'))
+ if queue else None) or 0
+ ctx['write_large'], ctx['write_large_why'] = LARGE_IO, ''
+ # NFSD writes the misaligned start and end of a WRITE up to page
+ # boundaries with buffered IO. With large pages, a misaligned WRITE
+ # needs several pages to have a direct middle.
+ if LARGE_WRITE_PAGES * PAGE_SIZE > ctx['write_large']:
+ ctx['write_large'] = LARGE_WRITE_PAGES * PAGE_SIZE
+ ctx['write_large_why'] = ' (%d pages)' % LARGE_WRITE_PAGES
+ if ctx['write_large'] < ctx['io_opt'] <= MAX_IO_OPT:
+ ctx['write_large'] = ctx['io_opt']
+ ctx['write_large_why'] = ' (the optimal IO size of the storage)'
+
+ probe = pick_files(path, 1, 20)
+ ctx['dontcache'] = probe_dontcache(probe)
+ ctx['direct_read'], ctx['direct_write'], ctx['dio_mem_align'] = \
+ probe_direct(probe)
+ # WRITE data received over TCP is aligned to only XDR_ALIGN bytes in
+ # memory. NFSD writes it with buffered IO if the block device needs a
+ # larger memory alignment, as device mapper and MD devices do.
+ ctx['direct_write_why'] = None
+ ctx['rdma'] = rdma_listener()
+ if ctx['direct_write'] and ctx['dio_mem_align'] > XDR_ALIGN:
+ ctx['direct_write'] = False
+ ctx['direct_write_why'] = (
+ 'NFSD cannot write to this filesystem with direct IO. Its block '
+ 'device needs %d-byte memory alignment, and WRITE data that '
+ 'arrives over TCP has only %d-byte alignment.' %
+ (ctx['dio_mem_align'], XDR_ALIGN))
+ if args.sample_files > 0:
+ ctx['cachestat'] = Cachestat()
+ ctx['files'] = pick_files(path, args.sample_files,
+ max(args.sample_files * 20, FILE_SCAN))
+ if ctx['files'] and ctx['cachestat'](ctx['files'][0]) is None:
+ print('cachestat(2) is not available. File sampling is off.',
+ file=sys.stderr)
+ ctx['files'] = []
+
+ try:
+ a, b, dirty = measure(ctx, args.interval)
+ except KeyboardInterrupt:
+ sys.exit(1)
+
+ m = analyze(ctx, a, b, dirty)
+ rec = recommend(ctx, m)
+ if args.json:
+ out = {'export': path, 'fstype': fstype, 'source': source,
+ 'bdi': ctx['bdi'],
+ 'dontcache': ctx['dontcache'],
+ 'direct': {'read': ctx['direct_read'],
+ 'write': ctx['direct_write'],
+ 'mem_align': ctx['dio_mem_align'],
+ 'write_reason': ctx['direct_write_why']},
+ 'storage': {'rotational': ctx['rotational'],
+ 'optimal_io_size': ctx['io_opt'],
+ 'large_write': ctx['write_large']},
+ 'current': {'riomode': ctx['cur_read'],
+ 'wiomode': ctx['cur_write'],
+ 'riomode_source': ctx['read_src'],
+ 'wiomode_source': ctx['write_src']},
+ 'metrics': {**m, 'fs_exports': sorted(fs_exports)},
+ 'recommendation': {'riomode': rec[0], 'read_reasons': rec[1],
+ 'wiomode': rec[2], 'write_reasons': rec[3]}}
+ print(json.dumps(out, indent=2))
+ else:
+ report(ctx, m, rec)
+
+
+if __name__ == '__main__':
+ main()
--
2.56.0
^ permalink raw reply related [flat|nested] 5+ messages in thread
end of thread, other threads:[~2026-10-27 16:50 UTC | newest]
Thread overview: 5+ messages (download: mbox.gz follow: Atom feed
-- links below jump to the message on this page --
2026-10-27 16:50 [PATCH nfs-utils 0/4] exports: add support for new riomode= and wiomode= export options Jeff Layton
2026-10-27 16:50 ` [PATCH nfs-utils 1/4] exportfs/mountd/exportd: track the svc_export policy highest attribute Jeff Layton
2026-10-27 16:50 ` [PATCH nfs-utils 2/4] exports: Add riomode= and wiomode= export options Jeff Layton
2026-10-27 16:50 ` [PATCH nfs-utils 3/4] exportfs: Display and document riomode= and wiomode= Jeff Layton
2026-10-27 16:50 ` [PATCH nfs-utils 4/4] nfsd-iomode-advisor: recommend riomode= and wiomode= for an export Jeff Layton
This is an external index of several public inboxes,
see mirroring instructions on how to clone and mirror
all data and code used by this external index.