Skip to content

Commit 263f2fe

Browse files
committed
statd: optimize yanger invocation time and reduce command overhead
Replace logging + logging.handlers with a lightweight syslog wrapper, and argparse with manual argv parsing. On a sama7g54, this cuts yanger startup from ~770ms to ~470ms by eliminating ~300ms of stdlib imports. Also batch external command invocations: - ietf_routing: two sysctl calls instead of two per interface - ietf_hardware: one ls per hwmon device instead of six - bridge: fetch mctl querier data once instead of once per VLAN Signed-off-by: Joachim Wiberg <troglobit@gmail.com>
1 parent 921016b commit 263f2fe

5 files changed

Lines changed: 174 additions & 76 deletions

File tree

src/statd/python/yanger/__main__.py

Lines changed: 88 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -1,97 +1,130 @@
1-
import logging
2-
import logging.handlers
31
import json
4-
import sys # (built-in module)
52
import os
6-
import argparse
3+
import sys
74

85
from . import common
96
from . import host
107

11-
def main():
12-
def dirpath(path):
13-
if not os.path.isdir(path):
14-
raise argparse.ArgumentTypeError(f"'{path}' is not a valid directory")
15-
return path
8+
USAGE = """\
9+
usage: yanger [-p PARAM] [-x PREFIX] [-r DIR | -c DIR] model
1610
17-
parser = argparse.ArgumentParser(description="YANG data creator")
18-
parser.add_argument("model", help="YANG Model")
19-
parser.add_argument("-p", "--param",
20-
help="Model dependent parameter, e.g. interface name")
21-
parser.add_argument("-x", "--cmd-prefix", metavar="PREFIX",
22-
help="Use this prefix for all system commands, e.g. " +
23-
"'ssh user@remotehost sudo'")
11+
YANG data creator
2412
25-
rrparser = parser.add_mutually_exclusive_group()
26-
rrparser.add_argument("-r", "--replay", type=dirpath, metavar="DIR",
27-
help="Generate output based on recorded system commands from DIR, " +
28-
"rather than querying the local system")
29-
rrparser.add_argument("-c", "--capture", metavar="DIR",
30-
help="Capture system command output in DIR, such that the current system " +
31-
"state can be recreated offline (with --replay) for testing purposes")
13+
positional arguments:
14+
model YANG Model
3215
33-
args = parser.parse_args()
34-
if args.replay and args.cmd_prefix:
35-
parser.error("--cmd-prefix cannot be used with --replay")
16+
options:
17+
-p, --param PARAM Model dependent parameter, e.g. interface name
18+
-x, --cmd-prefix PREFIX
19+
Use this prefix for all system commands, e.g.
20+
'ssh user@remotehost sudo'
21+
-r, --replay DIR Generate output based on recorded system commands
22+
from DIR, rather than querying the local system
23+
-c, --capture DIR Capture system command output in DIR, such that the
24+
current system state can be recreated offline (with
25+
--replay) for testing purposes
26+
"""
3627

37-
# Set up syslog output for critical errors to aid debugging
38-
common.LOG = logging.getLogger('yanger')
39-
if os.path.exists('/dev/log'):
40-
log = logging.handlers.SysLogHandler(address='/dev/log')
41-
else:
42-
# Use /dev/null as a fallback for unit tests
43-
log = logging.FileHandler('/dev/null')
28+
def _parse_args(argv):
29+
model = None
30+
param = None
31+
cmd_prefix = None
32+
replay = None
33+
capture = None
34+
35+
i = 1
36+
while i < len(argv):
37+
arg = argv[i]
38+
if arg in ('-h', '--help'):
39+
sys.stdout.write(USAGE)
40+
sys.exit(0)
41+
elif arg in ('-p', '--param'):
42+
i += 1
43+
if i >= len(argv):
44+
sys.exit(f"error: {arg} requires an argument")
45+
param = argv[i]
46+
elif arg in ('-x', '--cmd-prefix'):
47+
i += 1
48+
if i >= len(argv):
49+
sys.exit(f"error: {arg} requires an argument")
50+
cmd_prefix = argv[i]
51+
elif arg in ('-r', '--replay'):
52+
i += 1
53+
if i >= len(argv):
54+
sys.exit(f"error: {arg} requires an argument")
55+
replay = argv[i]
56+
if not os.path.isdir(replay):
57+
sys.exit(f"error: '{replay}' is not a valid directory")
58+
elif arg in ('-c', '--capture'):
59+
i += 1
60+
if i >= len(argv):
61+
sys.exit(f"error: {arg} requires an argument")
62+
capture = argv[i]
63+
elif arg.startswith('-'):
64+
sys.exit(f"error: unknown option: {arg}")
65+
elif model is None:
66+
model = arg
67+
else:
68+
sys.exit(f"error: unexpected argument: {arg}")
69+
i += 1
4470

45-
fmt = logging.Formatter('%(name)s[%(process)d]: %(message)s')
46-
log.setFormatter(fmt)
47-
common.LOG.setLevel(logging.INFO)
48-
common.LOG.addHandler(log)
71+
if model is None:
72+
sys.exit("error: missing required argument: model")
73+
if replay and cmd_prefix:
74+
sys.exit("error: --cmd-prefix cannot be used with --replay")
75+
if replay and capture:
76+
sys.exit("error: --replay cannot be used with --capture")
77+
78+
return model, param, cmd_prefix, replay, capture
79+
80+
def main():
81+
model, param, cmd_prefix, replay, capture = _parse_args(sys.argv)
4982

50-
if args.cmd_prefix or args.capture:
51-
host.HOST = host.Remotehost(args.cmd_prefix, args.capture)
52-
elif args.replay:
53-
host.HOST = host.Replayhost(args.replay)
83+
if cmd_prefix or capture:
84+
host.HOST = host.Remotehost(cmd_prefix, capture)
85+
elif replay:
86+
host.HOST = host.Replayhost(replay)
5487
else:
5588
host.HOST = host.Localhost()
5689

57-
if args.model == 'ietf-interfaces':
90+
if model == 'ietf-interfaces':
5891
from . import ietf_interfaces
59-
yang_data = ietf_interfaces.operational(args.param)
60-
elif args.model == 'ietf-routing':
92+
yang_data = ietf_interfaces.operational(param)
93+
elif model == 'ietf-routing':
6194
from . import ietf_routing
6295
yang_data = ietf_routing.operational()
63-
elif args.model == 'ietf-ospf':
96+
elif model == 'ietf-ospf':
6497
from . import ietf_ospf
6598
yang_data = ietf_ospf.operational()
66-
elif args.model == 'ietf-rip':
99+
elif model == 'ietf-rip':
67100
from . import ietf_rip
68101
yang_data = ietf_rip.operational()
69-
elif args.model == 'ietf-hardware':
102+
elif model == 'ietf-hardware':
70103
from . import ietf_hardware
71104
yang_data = ietf_hardware.operational()
72-
elif args.model == 'infix-containers':
105+
elif model == 'infix-containers':
73106
from . import infix_containers
74107
yang_data = infix_containers.operational()
75-
elif args.model == 'infix-dhcp-server':
108+
elif model == 'infix-dhcp-server':
76109
from . import infix_dhcp_server
77110
yang_data = infix_dhcp_server.operational()
78-
elif args.model == 'ietf-system':
111+
elif model == 'ietf-system':
79112
from . import ietf_system
80113
yang_data = ietf_system.operational()
81-
elif args.model == 'ietf-ntp':
114+
elif model == 'ietf-ntp':
82115
from . import ietf_ntp
83116
yang_data = ietf_ntp.operational()
84-
elif args.model == 'ieee802-dot1ab-lldp':
117+
elif model == 'ieee802-dot1ab-lldp':
85118
from . import infix_lldp
86119
yang_data = infix_lldp.operational()
87-
elif args.model == 'infix-firewall':
120+
elif model == 'infix-firewall':
88121
from . import infix_firewall
89122
yang_data = infix_firewall.operational()
90-
elif args.model == 'ietf-bfd-ip-sh':
123+
elif model == 'ietf-bfd-ip-sh':
91124
from . import ietf_bfd_ip_sh
92125
yang_data = ietf_bfd_ip_sh.operational()
93126
else:
94-
common.LOG.warning("Unsupported model %s", args.model)
127+
common.LOG.warning("Unsupported model %s", model)
95128
sys.exit(1)
96129

97130
print(json.dumps(yang_data, indent=2, ensure_ascii=False))

src/statd/python/yanger/common.py

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,50 @@
1+
import syslog
12
from datetime import timedelta
23

34
from . import host
45

5-
LOG = None
6+
7+
class SysLog:
8+
"""Lightweight syslog wrapper replacing the logging module.
9+
10+
Provides the same .error()/.warning()/.info()/.debug() interface
11+
used throughout yanger, but uses the C syslog facility directly,
12+
avoiding the ~374ms import overhead of logging + logging.handlers.
13+
"""
14+
15+
DEBUG = syslog.LOG_DEBUG
16+
INFO = syslog.LOG_INFO
17+
WARNING = syslog.LOG_WARNING
18+
ERROR = syslog.LOG_ERR
19+
20+
def __init__(self, name):
21+
syslog.openlog(name, syslog.LOG_PID)
22+
self._level = self.INFO
23+
24+
def setLevel(self, level):
25+
self._level = level
26+
27+
def _log(self, level, msg, *args):
28+
if level > self._level:
29+
return
30+
if args:
31+
msg = msg % args
32+
syslog.syslog(level, msg)
33+
34+
def debug(self, msg, *args):
35+
self._log(self.DEBUG, msg, *args)
36+
37+
def info(self, msg, *args):
38+
self._log(self.INFO, msg, *args)
39+
40+
def warning(self, msg, *args):
41+
self._log(self.WARNING, msg, *args)
42+
43+
def error(self, msg, *args):
44+
self._log(self.ERROR, msg, *args)
45+
46+
47+
LOG = SysLog("yanger")
648

749
class YangDate:
850
def __init__(self, dt=None):

src/statd/python/yanger/ietf_hardware.py

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -265,8 +265,11 @@ def create_sensor(sensor_name, value, value_type, value_scale, label=None):
265265
component["description"] = desc
266266
return component
267267

268+
# List hwmon directory once, reuse for all sensor types
269+
all_entries = HOST.run(("ls", hwmon_path), default="").split()
270+
268271
# Temperature sensors
269-
temp_entries = HOST.run(("ls", hwmon_path), default="").split()
272+
temp_entries = all_entries
270273
temp_files = [os.path.join(hwmon_path, e) for e in temp_entries if e.startswith("temp") and e.endswith("_input")]
271274
for temp_file in temp_files:
272275
try:
@@ -285,7 +288,7 @@ def create_sensor(sensor_name, value, value_type, value_scale, label=None):
285288
continue
286289

287290
# Fan sensors (RPM from tachometer)
288-
fan_entries = HOST.run(("ls", hwmon_path), default="").split()
291+
fan_entries = all_entries
289292
fan_files = [os.path.join(hwmon_path, e) for e in fan_entries if e.startswith("fan") and e.endswith("_input")]
290293
for fan_file in fan_files:
291294
try:
@@ -307,7 +310,7 @@ def create_sensor(sensor_name, value, value_type, value_scale, label=None):
307310
# Only add if no fan*_input exists for this device (avoid duplicates)
308311
has_rpm_sensor = bool(fan_files)
309312
if not has_rpm_sensor:
310-
pwm_entries = HOST.run(("ls", hwmon_path), default="").split()
313+
pwm_entries = all_entries
311314
pwm_files = [os.path.join(hwmon_path, e) for e in pwm_entries if e.startswith("pwm") and e[3:].replace('_', '').isdigit() if len(e) > 3]
312315
for pwm_file in pwm_files:
313316
# Skip pwm*_enable, pwm*_mode, etc. - only process pwm1, pwm2, etc.
@@ -336,7 +339,7 @@ def create_sensor(sensor_name, value, value_type, value_scale, label=None):
336339
continue
337340

338341
# Voltage sensors
339-
voltage_entries = HOST.run(("ls", hwmon_path), default="").split()
342+
voltage_entries = all_entries
340343
voltage_files = [os.path.join(hwmon_path, e) for e in voltage_entries if e.startswith("in") and e.endswith("_input")]
341344
for voltage_file in voltage_files:
342345
try:
@@ -356,7 +359,7 @@ def create_sensor(sensor_name, value, value_type, value_scale, label=None):
356359
continue
357360

358361
# Current sensors
359-
current_entries = HOST.run(("ls", hwmon_path), default="").split()
362+
current_entries = all_entries
360363
current_files = [os.path.join(hwmon_path, e) for e in current_entries if e.startswith("curr") and e.endswith("_input")]
361364
for current_file in current_files:
362365
try:
@@ -376,7 +379,7 @@ def create_sensor(sensor_name, value, value_type, value_scale, label=None):
376379
continue
377380

378381
# Power sensors
379-
power_entries = HOST.run(("ls", hwmon_path), default="").split()
382+
power_entries = all_entries
380383
power_files = [os.path.join(hwmon_path, e) for e in power_entries if e.startswith("power") and e.endswith("_input")]
381384
for power_file in power_files:
382385
try:

src/statd/python/yanger/ietf_interfaces/bridge.py

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -204,10 +204,13 @@ def mctlq2yang_mode(mctlq):
204204
return "off"
205205

206206

207-
def mctl(ifname, vid):
208-
mctl = HOST.run_json(["mctl", "-p", "show", "igmp", "json"], default={})
207+
def mctl_queriers():
208+
"""Fetch all IGMP multicast querier data in one call"""
209+
return HOST.run_json(["mctl", "-p", "show", "igmp", "json"], default={})
209210

210-
for q in mctl.get("multicast-queriers", []):
211+
212+
def mctl(ifname, vid, mctldata):
213+
for q in mctldata.get("multicast-queriers", []):
211214
# TODO: Also need to match against VLAN uppers (e.g. br0.1337)
212215
if q.get("interface") == ifname and q.get("vid") == vid:
213216
return q
@@ -239,8 +242,8 @@ def multicast_filters(iplink, vid):
239242
return { "multicast-filter": list(mdb.values()) }
240243

241244

242-
def multicast(iplink, info):
243-
mctlq = mctl(iplink["ifname"], info.get("vlan"))
245+
def multicast(iplink, info, mctldata):
246+
mctlq = mctl(iplink["ifname"], info.get("vlan"), mctldata)
244247

245248
mcast = {
246249
"snooping": bool(info.get("mcast_snooping")),
@@ -276,13 +279,15 @@ def vlans(iplink):
276279
if not (brgvlans := HOST.run_json(f"bridge -j vlan global show dev {iplink['ifname']}".split())):
277280
return []
278281

282+
mctldata = mctl_queriers()
283+
279284
vlans = {
280285
v["vlan"]: {
281286
"vid": v["vlan"],
282287
"untagged": [],
283288
"tagged": [],
284289

285-
"multicast": multicast(iplink, v),
290+
"multicast": multicast(iplink, v, mctldata),
286291
"multicast-filters": multicast_filters(iplink, v["vlan"]),
287292
}
288293
for v in brgvlans[0]["vlans"]
@@ -307,7 +312,7 @@ def dbridge(iplink):
307312
info = iplink["linkinfo"]["info_data"]
308313

309314
return {
310-
"multicast": multicast(iplink, info),
315+
"multicast": multicast(iplink, info, mctl_queriers()),
311316
"multicast-filters": multicast_filters(iplink, None),
312317
}
313318

src/statd/python/yanger/ietf_routing.py

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -132,19 +132,34 @@ def get_routing_interfaces():
132132
links_json = HOST.run(tuple(['ip', '-j', 'link', 'show']), default="[]")
133133
links = json.loads(links_json)
134134

135+
# Fetch all forwarding sysctls in two calls instead of 2 per interface
136+
ipv4_sysctls = HOST.run(tuple(['sysctl', 'net.ipv4.conf']), default="")
137+
ipv6_sysctls = HOST.run(tuple(['sysctl', 'net.ipv6.conf']), default="")
138+
139+
# Parse "net.ipv4.conf.<iface>.forwarding = 1" lines into a set
140+
ipv4_fwd = set()
141+
ipv6_fwd = set()
142+
for line in ipv4_sysctls.splitlines():
143+
if '.forwarding = 1' in line:
144+
# net.ipv4.conf.IFNAME.forwarding = 1
145+
parts = line.split('.')
146+
if len(parts) >= 5:
147+
ipv4_fwd.add(parts[3])
148+
149+
for line in ipv6_sysctls.splitlines():
150+
if '.force_forwarding = 1' in line:
151+
# net.ipv6.conf.IFNAME.force_forwarding = 1
152+
parts = line.split('.')
153+
if len(parts) >= 5:
154+
ipv6_fwd.add(parts[3])
155+
135156
routing_ifaces = []
136157
for link in links:
137158
ifname = link.get('ifname')
138159
if not ifname:
139160
continue
140161

141-
# Check if IPv4 forwarding is enabled
142-
ipv4_fwd = HOST.run(tuple(['sysctl', '-n', f'net.ipv4.conf.{ifname}.forwarding']), default="0").strip()
143-
144-
# Check if IPv6 force_forwarding is enabled (available since Linux 6.17)
145-
ipv6_fwd = HOST.run(tuple(['sysctl', '-n', f'net.ipv6.conf.{ifname}.force_forwarding']), default="0").strip()
146-
147-
if ipv4_fwd == "1" or ipv6_fwd == "1":
162+
if ifname in ipv4_fwd or ifname in ipv6_fwd:
148163
routing_ifaces.append(ifname)
149164

150165
return routing_ifaces

0 commit comments

Comments
 (0)