#!/usr/bin/env python3 # xcapture-bpf -- Always-on profiling of Linux thread activity, by Tanel Poder [https://tanelpoder.com] # Copyright 2024 Tanel Poder # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License along # with this program; if not, write to the Free Software Foundation, Inc., # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # SPDX-License-Identifier: GPL-2.0-or-later __version__ = "2.0.3" __author__ = "Tanel Poder" __date__ = "2024-06-27" __description__ = "Always-on profiling of Linux thread activity using eBPF." __url__ = "https://0x.tools" DEFAULT_GROUP_BY = "st,username,comm,syscall" # for xtop mode DECODE_CHARSET = "utf-8" XTOP_MAX_LINES = 25 # how many top output lines to print BLOCK_CHARS = ['▏', '▎', '▍', '▌', '▋', '▊', '▉', '█'] # for fancy viz import os, sys, io, pwd, time, ctypes, platform, re, shutil, argparse, signal from collections import defaultdict from datetime import datetime from bcc import BPF, PerfType, PerfSWConfig # distro package might not be present try: import distro except ImportError: distro = None pass # all available fields with descriptions (if you add more fields to thread_state_t in BPF/C, add them here) available_fdescr = [ ('timestamp' , 'sample timestamp') , ('st' , 'short thread state') , ('tid' , 'thread/task id') , ('pid' , 'process/thread group id') , ('username' , 'username or user id if not found') , ('comm' , 'task comm digits deduplicated') , ('comm2' , 'task comm actual') , ('syscall' , 'system call') , ('cmdline' , 'argv0 command line digits deduplicated') , ('cmdline2' , 'argv0 command line actual') , ('offcpu_u' , 'user stack id when thread went off CPU') , ('offcpu_k' , 'kernel stack id when thread went off CPU') , ('oncpu_u' , 'recent user stack id if the thread was on CPU') , ('oncpu_k' , 'recent kernel stack id if the thread was on CPU') , ('waker_tid' , 'thread ID that woke up this thread last') , ('sch' , 'thread state flags for scheduler nerds') ] available_fields = [] for f in available_fdescr: available_fields.append(f[0]) # default output fields for ungrouped full detail output output_fields = [ 'timestamp', 'st', 'tid', 'pid', 'username', 'comm', 'syscall', 'cmdline' , 'offcpu_u', 'offcpu_k', 'oncpu_u', 'oncpu_k', 'waker_tid', 'sch' ] # syscall id to name translation (todo: fix aarch64 include file lookup) def extract_system_call_ids(unistd_64_fh): syscall_id_to_name = {} # strip 3264bit prefixes from syscall names for name_prefix in ['__NR_', '__NR3264_']: for line in unistd_64_fh.readlines(): tokens = line.split() if tokens and len(tokens) == 3 and tokens[0] == '#define' and tokens[2].isnumeric() is True: _, s_name, s_id = tokens s_id = int(s_id) if s_name.startswith(name_prefix): s_name = s_name[len(name_prefix):] syscall_id_to_name[s_id] = s_name return syscall_id_to_name def get_system_call_names(): psn_dir=os.path.dirname(os.path.realpath(__file__)) kernel_ver=platform.release().split('-')[0] # this probably needs to be improved for better platform support if platform.machine() == 'aarch64': unistd_64_paths = ['/usr/include/asm-generic/unistd.h'] else: unistd_64_paths = [ '/usr/include/asm/unistd_64.h', '/usr/include/x86_64-linux-gnu/asm/unistd_64.h' , '/usr/include/asm-x86_64/unistd.h', '/usr/include/asm/unistd.h' , psn_dir+'/syscall_64_'+kernel_ver+'.h', psn_dir+'/syscall_64.h'] for path in unistd_64_paths: try: with open(path) as f: return extract_system_call_ids(f) except IOError as e: pass raise Exception('unistd_64.h not found in' + ' or '.join(unistd_64_paths) + '.\n' + ' You may need to "dnf install kernel-headers" or "apt-get install libc6-dev"\n') # syscall lookup table syscall_id_to_name = get_system_call_names() # task states TASK_RUNNING = 0x00000000 TASK_INTERRUPTIBLE = 0x00000001 TASK_UNINTERRUPTIBLE = 0x00000002 TASK_STOPPED = 0x00000004 TASK_TRACED = 0x00000008 EXIT_DEAD = 0x00000010 EXIT_ZOMBIE = 0x00000020 EXIT_TRACE = (EXIT_ZOMBIE | EXIT_DEAD) TASK_PARKED = 0x00000040 TASK_DEAD = 0x00000080 TASK_WAKEKILL = 0x00000100 TASK_WAKING = 0x00000200 TASK_NOLOAD = 0x00000400 TASK_NEW = 0x00000800 TASK_RTLOCK_WAIT = 0x00001000 TASK_FREEZABLE = 0x00002000 TASK_FREEZABLE_UNSAFE = 0x00004000 # depends on: IS_ENABLED(CONFIG_LOCKDEP) TASK_FROZEN = 0x00008000 TASK_STATE_MAX = 0x00010000 # as of linux kernel 6.9 ##define TASK_STATE_TO_CHAR_STR "RSDTtXZxKWPN" task_states = { 0x00000000: "R", # "RUNNING", 0x00000001: "S", # "INTERRUPTIBLE", 0x00000002: "D", # UNINTERRUPTIBLE", 0x00000004: "T", # "STOPPED", 0x00000008: "t", # "TRACED", 0x00000010: "X", # "EXIT_DEAD", 0x00000020: "Z", # "EXIT_ZOMBIE", 0x00000040: "P", # "PARKED", 0x00000080: "dd",# "DEAD", 0x00000100: "wk",# "WAKEKILL", 0x00000200: "wg",# "WAKING", 0x00000400: "I", # "NOLOAD", 0x00000800: "N", # "NEW", 0x00001000: "rt",# "RTLOCK_WAIT", 0x00002000: "fe",# "FREEZABLE", 0x00004000: "fu",# "__TASK_FREEZABLE_UNSAFE = (0x00004000 * IS_ENABLED(CONFIG_LOCKDEP))" 0x00008000: "fo",# "FROZEN" } def get_task_state_name(task_state): if task_state == 0: return "R" if task_state & TASK_NOLOAD: # idle kthread waiting for work return "I" names = [] for state, name in task_states.items(): if task_state & state: names.append(name) return "+".join(names) # is task state interesting ("active") according to your rules # mode=active: any states that should be captured and printed out (including perf/on-cpu samples) # mode=offcpu: states that are relevant for offcpu stack printing (the BPF program doesn't clear up previous offcpu stackids) # mode=oncpu: states that are relevant for on-cpu stack printing (don't print previous oncpu stacks if a task sample is not on CPU) def is_interesting(st, syscall, comm, mode="active"): if mode == "active": if st[0] in ['R','D', 'T', 't']: return True if st[0] == 'S': if current_syscall == 'io_getevents' and comm.startswith('ora'): return True if mode == "offcpu": if st[0] in ['D', 'T', 't'] or st.startswith('RQ'): # there may be occasinal states like "D+wk" reported return True if st[0] == 'S': if current_syscall == 'io_getevents' and comm.startswith('ora'): return True if mode == "oncpu": if st[0] == 'R': return True return False # translate uid to username (no container/uid namespace support right now) def get_username(uid): try: username = pwd.getpwuid(uid).pw_name return username except KeyError: return str(uid) def print_fields(rows, columns, linelimit=0): columns = [col.rstrip() for col in columns] # strip as colname might have extra spaces passed in for width/formatting col_widths = {} # column width auto-sizing for col in columns: col_length = len(col) # the col may have extra trailing spaces as a formatting directive max_value_length = max((len(str(row[col])) for row in rows if col in row), default=0) col_widths[col] = max(col_length, max_value_length) header1 = "=== Active Threads " header2 = " | ".join(f"{col:<{col_widths[col]}}" for col in columns) print(header1 + "=" * (len(header2) - len(header1)) + "\n") print(header2) print("-" * len(header2)) for i, row in enumerate(rows): line = " | ".join( f"{row[col]:>{col_widths[col]}.2f}" if col in ["seconds", "samples", "avg_thr"] else f"{str(row[col]):<{col_widths[col]}}" if col in row else ' ' * col_widths[col] for col in columns ) print(line) # dont break out if linelimit is at its default 0 if linelimit and i >= linelimit - 1: break def print_header_csv(columns): header = ",".join(f"{col.upper()}" for col in columns) print(header) def print_fields_csv(rows, columns): for i, row in enumerate(rows): line = ",".join(f"{row[col]}" for col in columns) print(line) def get_ustack_traces(ustack_traces, ignore_ustacks={}, strip_args=True): exclusions = ['__GI___clone3'] dedup_map = {} lines = [] for stack_id, pid in output_ustack: if stack_id and stack_id >= 0 and stack_id not in ignore_ustacks: # todo: find why we have Null/none stackids in this map line = f"ustack {stack_id:6} " stack = list(ustack_traces.walk(stack_id)) for addr in reversed(stack): # reversed(stack): func_name = b.sym(addr, pid).decode(DECODE_CHARSET, 'replace') if func_name not in exclusions: if strip_args: func_name = re.split('[<(]', func_name)[0] line += "->" + (func_name if func_name != '[unknown]' else '{:x}'.format(addr)) dedup_map[stack_id] = line for stack_id in sorted(dedup_map): lines.append(dedup_map[stack_id]) return lines def get_kstack_traces(kstack_traces, ignore_kstacks={}): exclusions = ['entry_SYSCALL_64_after_hwframe', 'do_syscall_64', 'x64_sys_call' , 'ret_from_fork_asm', 'ret_from_fork', '__bpf_trace_sched_switch', '__traceiter_sched_switch' , 'el0t_64_sync', 'el0t_64_sync_handler', 'el0_svc', 'do_el0_svc', 'el0_svc_common', 'invoke_syscall' ] lines = [] for k, v in kstack_traces.items(): stack_id = k.value if stack_id in output_kstack and stack_id not in ignore_kstacks: line = f"kstack {stack_id:6} " if stack_id >= 0: stack = list(kstack_traces.walk(stack_id)) for addr in reversed(stack): func = b.ksym(addr).decode(DECODE_CHARSET, 'replace') if func not in exclusions and not func.startswith('bpf_'): line += "->" + b.ksym(addr).decode(DECODE_CHARSET, 'replace') lines.append(line) return lines def pivot_stack_traces(traces): pivoted_traces = [] for trace in traces: parts = trace.split("->") pivoted_traces.append(parts) max_length = max(len(trace) for trace in pivoted_traces) for trace in pivoted_traces: while len(trace) < max_length: trace.append("") return pivoted_traces def calculate_columns(pivoted_traces, max_line_length): max_length = max(len(part) for trace in pivoted_traces for part in trace) return max(1, max_line_length // (max_length + 3)) def print_pivoted_dynamic(traces, max_line_length): num_traces = len(traces) start = 0 while start < num_traces: end = start + 1 while end <= num_traces: subset_traces = traces[start:end] pivoted_traces = pivot_stack_traces(subset_traces) num_columns = calculate_columns(pivoted_traces, max_line_length) if num_columns < end - start: break end += 1 end -= 1 subset_traces = traces[start:end] pivoted_traces = pivot_stack_traces(subset_traces) max_length = max(len(part) for trace in pivoted_traces for part in trace) print("-" * max_line_length) for row in zip(*pivoted_traces): print(" | ".join(f"{part:<{max_length}}" for part in row) + ' |') start = end # stack printing and formatting choice driver function def print_stacks_if_nerdmode(): if args.giant_nerd_mode and stackmap: # printing stacktiles first, so the task state info is in the bottom of terminal output (term_width, term_height) = shutil.get_terminal_size() print_pivoted_dynamic(get_kstack_traces(stackmap), max_line_length=term_width) print() print_pivoted_dynamic(get_ustack_traces(stackmap), max_line_length=term_width) print() if args.nerd_mode: for s in get_kstack_traces(stackmap): print(s) print() for s in get_ustack_traces(stackmap): print(s) # group by for reporting def group_by(records, column_names, sample_attempts_in_set, time_range_in_set): total_records = len(records) grouped_data = defaultdict(lambda: {'samples': 0}) for record in records: key = tuple(record[col] for col in column_names) if key not in grouped_data: grouped_data[key].update({col: record[col] for col in column_names}) grouped_data[key]['samples'] += 1 grouped_list = list(grouped_data.values()) for item in grouped_list: item['avg_thr'] = round(item['samples'] / sample_attempts_in_set, 2) item['seconds'] = round(item['samples'] * (time_range_in_set / sample_attempts_in_set), 2) # fancy viz pct = item['samples'] / total_records full_blocks = int(pct * 10) remainder = (pct * 80) % 8 visual = '█' * full_blocks if remainder > 0: visual += BLOCK_CHARS[int(remainder)] item['visual_pct'] = visual #ascii also possible #item['visual_pct'] = '#' * int(pct * 10) return grouped_list # main() signal.signal(signal.SIGPIPE, signal.SIG_DFL) # args parser = argparse.ArgumentParser(description=__description__) parser.add_argument('-x', '--xtop', action='store_true', help='Run in aggregated top-thread-activity (xtop) mode') parser.add_argument('-d', dest="report_seconds", metavar='report_seconds', type=int, default=5, help='xtop report printing interval (default: %(default)ds)') parser.add_argument('-f', '--sample-hz', default=20, type=int, help='xtop sampling frequency in Hz (default: %(default)d)') parser.add_argument('-g', '--group-by', metavar='csv-columns', default=DEFAULT_GROUP_BY, help='Full column list what to group by') parser.add_argument('-G', '--append-group-by', metavar='append-csv-columns', default=None, help='List of additional columns to default cols what to group by') parser.add_argument('-n', '--nerd-mode', action='store_true', help='Print out relevant stack traces as wide output lines') parser.add_argument('-N', '--giant-nerd-mode', action='store_true', help='Print out relevant stack traces as stacktiles') parser.add_argument('-c', '--clear-screen', action='store_true', help='Clear screen before printing next output') parser.add_argument('-V', '--version', action='version', version=f"%(prog)s {__version__} by {__author__} [{__url__}]", help='Show the program version and exit') parser.add_argument('-o', '--output-dir', type=str, default=None, help=f'Directory path where to write the output CSV files') parser.add_argument('-l', '--list', default=None, action='store_true', help='list all available columns for display and grouping') args = parser.parse_args() if args.list: for f in available_fdescr: print(f'{f[0]:15} {f[1]}') sys.exit(0) if args.clear_screen and args.output_dir: print("Error: --clear-screen (interactive) and --output-dir (continuous logging) are mutually exclusive, use only one option.") sys.exit(1) # handle xtop -g and -G group by columns (and same -g/-G options work for non-xtop output col addition too) # args.group_by defaults to DEFAULT_GROUP_BY groupby_fields = args.group_by.split(',') if args.xtop: groupby_fields = groupby_fields + args.append_group_by.split(',') if args.append_group_by else groupby_fields used_fields = groupby_fields # todo else: output_fields = output_fields + args.append_group_by.split(',') if args.append_group_by else output_fields used_fields = output_fields if set(used_fields) - set(available_fields): print("Error: incorrect group by field name specified, use --list option see allowed columns") exit(1) # eBPF programs have be loaded as root if os.geteuid() != 0: print("Error: you need to run this command as root") sys.exit(1) # ready to go progname = "xtop" if args.xtop else "xcapture-bpf" kernname = platform.release().split('-')[0] archname = platform.machine() distroid = distro.id().title() if distro else '' distrover = distro.version() if distro else '' sf = None # fd for separate stackfile in continuous csv sampling mode print(f'=== [0x.tools] {progname} {__version__} BETA by {__author__}. {distroid} Linux {distrover} {kernname} {archname}') # open and load the BPF instrumenter with open(os.path.dirname(os.path.abspath(__file__)) + '/xcapture-bpf.c', 'r') as file: bpf_text = file.read() # set up global variables for conditionally inserting stack capture code offcpu_u = 'offcpu_u' in used_fields offcpu_k = 'offcpu_k' in used_fields offcpu_stacks = offcpu_u or offcpu_k oncpu_stacks = ('oncpu_u' in used_fields or 'oncpu_k' in used_fields) cmdline = ('cmdline' in used_fields or 'cmdline2' in used_fields) # dynamic compilation of features that are needed ifdef = '' if offcpu_u: ifdef += '#define OFFCPU_U 1\n' if offcpu_k: ifdef += '#define OFFCPU_K 1\n' if offcpu_stacks: ifdef += '#define OFFCPU_STACKS 1\n' if oncpu_stacks: ifdef += '#define ONCPU_STACKS 1\n' if cmdline: ifdef += '#define CMDLINE 1\n' print('=== Loading BPF...') b = BPF(text= ifdef + bpf_text) # Software CPU_CLOCK is useful in cloud & VM environments where perf hardware events # are not available, but software clocks don't measure what happens when CPUs are in # critical sections when most interrupts are disabled b.attach_perf_event(ev_type=PerfType.SOFTWARE, ev_config=PerfSWConfig.CPU_CLOCK , fn_name="update_cpu_stack_profile" , sample_freq=2) # args.sample_hz if args.xtop else 1 # start sampling the Task State Array tsa = b.get_table("tsa") if oncpu_stacks or offcpu_stacks: stackmap = b.get_table("stackmap") else: stackmap = {} # get own pid so to not display it in output mypid = os.getpid() print(f"=== Ready (mypid {mypid})\n") # regex for replacing digits in "comm" for better grouping and reporting (comm2 shows original) trim_comm = re.compile(r'\d+') written_kstacks = {} # stack ids already written to csv (in -o mode) written_ustacks = {} first_report_printed = False # show first xtop report quicker csv_header_printed = False while True: try: output_kstack = {} # map of stack_ids seen so far output_ustack = {} output_records = [] sample_start = time.time() duration = (args.report_seconds if args.xtop and first_report_printed else 1) sample_end = sample_start + duration # todo: 1 Hz for raw/csv output for now first_report_printed = True samples_attempted = 0 # not all TSA samples contain active threads of interest, this tells us how many samples we really took while time.time() < sample_end: samples_attempted += 1 ts = datetime.now().strftime('%Y-%m-%d %H:%M:%S.%f') i = tsa.items()[0] for i in tsa.items(): save_record = True # extract python values from BPF ctypes, return '-' if there's no match fields_dict = {field[0]: getattr(i[1], field[0], '-') for field in i[1]._fields_} if fields_dict['tid'] == mypid: continue # additional fields for adding human readable info (not using None as it would be printed out as "None") fields_dict['st'] = '' fields_dict['sch'] = '' # for scheduler nerds fields_dict['state_flags'] = '' # full scheduler state bitmap fields_dict['username'] = '' fields_dict['syscall'] = '' fields_dict['comm2'] = '' fields_dict['cmdline2'] = '' current_syscall = syscall_id_to_name.get(fields_dict['syscall_id'], '-') if fields_dict['syscall_set'] else '-' comm = str(fields_dict['comm'], DECODE_CHARSET) in_sched_migrate = fields_dict['in_sched_migrate'] in_sched_wakeup = fields_dict['in_sched_wakeup'] in_sched_waking = fields_dict['in_sched_waking'] is_running_on_cpu = fields_dict['is_running_on_cpu'] # we use state for conditionally printing out things like offcpu_stack etc state_suffix = '' state = get_task_state_name(fields_dict['state']) if state == 'R' and not is_running_on_cpu: # runnable on runqueue state += 'Q' enriched_fields = {"timestamp": ts[:-3]} for field_name in fields_dict: if not field_name in used_fields: continue outv = None # enriched value if field_name in ['state', 'st']: if is_interesting(state, current_syscall, comm): outv = state else: save_record = False break elif field_name.startswith('comm'): val = fields_dict['comm'] # source field is "comm" regardless of potential comm2 output field name if isinstance(val, bytes): outv = str(val, DECODE_CHARSET) else: outv = str(val) if field_name == 'comm': # only trim "comm", but not comm2 that is the unaltered string outv = re.sub(trim_comm, '*', outv) elif field_name.startswith('cmdline'): val = fields_dict['cmdline'] if isinstance(val, bytes): outv = str(val, DECODE_CHARSET) else: outv = str(val) if field_name == 'cmdline': outv = re.sub(trim_comm, '*', outv) elif field_name == 'syscall': outv = current_syscall elif field_name == 'username': outv = get_username(fields_dict['uid']) elif field_name == ('offcpu_k'): # kstack id val = fields_dict[field_name] # runnable state can be R or RQ: RQ is also off CPU, so will capture it if is_interesting(state, current_syscall, comm, 'offcpu') and val > 0: outv = val output_kstack[val] = True else: outv = '-' elif field_name == ("offcpu_u"): # ustack id val = fields_dict[field_name] if is_interesting(state, current_syscall, comm, 'offcpu') and val > 0: outv = val # using pid/tgid here, address space is same for all threads in a process output_ustack[val, fields_dict['pid']] = True else: outv = '-' elif field_name == ('oncpu_k'): val = fields_dict[field_name] # only print the perf-cpu samples when actually caught on cpu (not runqueue) for now if is_interesting(state, current_syscall, comm, 'oncpu') and val > 0: outv = val output_kstack[val] = True else: outv = '-' elif field_name == ("oncpu_u"): val = fields_dict[field_name] if is_interesting(state, current_syscall, comm, 'oncpu') and val > 0: outv = val # using pid/tgid here, address space is same for all threads in a process output_ustack[val, fields_dict['pid']] = True else: outv = '-' elif field_name == 'sch': # (in_sched_waking, in_sched_wakeup, is_running_on_cpu) outv = '-' if in_sched_migrate else '_' outv += '-' if in_sched_waking else '_' outv += '-' if in_sched_wakeup else '_' outv += '-' if is_running_on_cpu else '_' else: val = fields_dict[field_name] if isinstance(val, bytes): outv = str(val, DECODE_CHARSET) else: outv = str(val) enriched_fields[field_name] = outv if save_record: output_records.append(enriched_fields) time.sleep(1 / (args.sample_hz if args.xtop else 1)) if output_records: # csv output mode will not do any terminal stuff if args.output_dir: outfile = args.output_dir + '/threads_' + ts[:13].replace(' ', '.') + '.csv' if os.path.isfile(outfile): # special case if xcapture-bpf has been restarted within the same hour csv_header_printed = True if sys.stdout.name != outfile: # create a new output file when the hour changes csv_header_printed = False # new file sys.stdout = open(outfile, 'a') if not csv_header_printed: print_header_csv(output_fields) csv_header_printed = True print_fields_csv(output_records, output_fields) # stackfile is created once and name doesn't change throughout xcapture process lifetime if not sf: stackfile = args.output_dir + '/stacks_' + ts[:13].replace(' ', '.') + '.csv' sf = open(stackfile, 'a') if sf: for s in get_kstack_traces(stackmap, ignore_kstacks=written_kstacks): print(s, file=sf) written_kstacks[int(s.split()[1])] = True #print(written_kstacks, file=sf) for s in get_ustack_traces(stackmap, ignore_ustacks=written_ustacks): print(s, file=sf) written_ustacks[int(s.split()[1])] = True #print(written_ustacks, file=sf) sf.flush() else: if args.clear_screen: # interactive (xtop) buffer = io.StringIO() sys.stdout = buffer print_stacks_if_nerdmode() print() print() if args.xtop: total_records = len(output_records) # a new field "samples" shows up (count(*)) grouped_list = group_by(output_records, groupby_fields, samples_attempted, sample_end - sample_start) ordered_aggr = sorted(grouped_list, key=lambda x: x['samples'], reverse=True) print_fields(ordered_aggr, ['seconds', 'avg_thr', 'visual_pct'] + groupby_fields, linelimit=XTOP_MAX_LINES) print() print() print(f'sampled: {samples_attempted} times, avg_thr: {round(total_records / samples_attempted, 2)}') print(f'start: {ts[:19]}, duration: {duration}s') if args.clear_screen: # terminal size may change over time (term_width, term_height) = shutil.get_terminal_size() for x in range(1, term_height - min(len(ordered_aggr), XTOP_MAX_LINES) - 9): # header/footer lines print() else: print() else: # wide raw terminal output print_fields(output_records, output_fields) print() print() if args.clear_screen: os.system('clear') output = buffer.getvalue() sys.stdout = sys.__stdout__ print(output) sys.stdout.flush() except KeyboardInterrupt: exit(0) #signal.signal(signal.SIGINT, signal.SIG_IGN) # That's all, folks!