awk Part IV · Module 14 · AWK Debugging, Review, and Engineering Mastery
Performance and Maintainability
Most advice about making awk faster is folklore, and this chapter measures it on a 400,000-record log. The result is that almost nothing inside the program matters: the entire spread between the fastest and slowest variant tested is 1.66×, anchoring a pattern bought exactly nothing, and referencing a field is free. The two things that do matter are how much input reaches awk and how many times you read it. The second half turns to the cost that is not measured in seconds — and finds that named field constants, the standard remedy for magic indices, do not protect against the change they appear to protect against.
Intermediate20 min readawkPart IVPerformanceMaintainabilityMeasurement
Part IV · Module 14 · Chapter 14.5 · Performance and Maintainability
1. Why This Matters
Everything in Part IV so far has been about being right. This chapter is about the two costs that remain once a script is right: the time it takes to run, and the time it takes the next engineer to change it.
The performance half is mostly a debunking exercise. awk is fast, the things people optimise are not the expensive ones, and the measurements below put numbers on that. The maintainability half is the part that actually pays, because a report-parsing script lives for years and gets edited by people who did not write it — and section 8 finds the standard defence against that failing in a way that produces a plausible wrong number.
2. Measuring, Not Guessing
#!/bin/sh
# bench.sh — median of five real runs for one command, in seconds.
#
# A single run is not a measurement: the first pass over a cold file is
# consistently slower, and ordinary scheduling noise is a few hundredths of a
# second, which is the same size as most of the effects being measured.
#
# Timing output can never be a verified code fence -- every run differs -- so
# this script's numbers are reported in the prose tables below, and the whole
# table was run three times to see which differences reproduce.
med() {
label=$1; shift
for i in 1 2 3 4 5; do
/usr/bin/time -p "$@" > /dev/null 2> t.err
awk '/^real/ { print $2 }' t.err
done | sort -n | awk -v L="$label" 'NR == 3 { printf " %-46s %6.2f s\n", L, $1 }'
}
med "baseline: pass every record, count" awk 'END { print NR }' big.log
med "regex /UVM_ERROR/" awk '/UVM_ERROR/ { n++ } END { print n }' big.log
# ... one med line per variantThree details of that runner matter:
/usr/bin/time -p, not the shell'stime. In zshtimeis a reserved word whose output does not follow the usual redirections, so it escapes a subshell capture. The external binary writesreal,userandsysto stderr in a fixed format.- Median of five, not a mean. A mean is dragged by the one slow run that every set of five contains. Chapter 12.2 adopted the same rule for the same reason.
sort -nthenNR == 3. Five values, the third in sorted order. No arithmetic, nothing to get wrong.
The input is a synthetic UVM-style log: 400,000 records, 24.5 MB, with 800 UVM_ERROR lines, about 4,100 UVM_WARNING, and the rest UVM_INFO. Each record carries a module name, a source path, a timestamp, a slack value and a verdict word, so every variant below has real fields to touch.
The whole table was run three times. Runs two and three agreed within 0.03 s on every row; run one was uniformly about 0.15 s higher, which is the cold-file effect. Nothing below rests on a difference smaller than the gap between those two runs.
3. What Costs Nothing
| variant | median | over baseline |
|---|---|---|
baseline — read every record, print NR at the end | 0.86 s | — |
{ x = $6; } arithmetic on a field, every record | 0.88 s | +0.02 |
$1 == "UVM_ERROR" | 0.89 s | +0.03 |
{ x = $NF } — reference the last field | 0.90 s | +0.04 |
{ x = $1 } — reference the first field | 0.91 s | +0.05 |
4. What Costs Something
| variant | median | over baseline |
|---|---|---|
index($0, "UVM_ERROR") | 0.93 s | +0.07 |
{ print $1 } — write every record | 0.96 s | +0.10 |
{ printf "%s\n", $1 } — same, with printf | 1.05 s | +0.19 |
array keyed on $2, 97 distinct keys | 1.09 s | +0.23 |
/^UVM_ERROR/ — anchored regex | 1.11 s | +0.25 |
/UVM_ERROR/ — unanchored regex | 1.12 s | +0.26 |
array keyed on $0, 400,000 distinct keys | 1.20 s | +0.34 |
{ sub(/u_mod/, "M") } guarded by /u_mod/ | 1.23 s | +0.37 |
{ gsub(/u_mod/, "M") } on every record | 1.43 s | +0.57 |
Four results in that table are worth stating as rules.
Anchoring a regex buys nothing. /^UVM_ERROR/ is 1.11 s and /UVM_ERROR/ is 1.12 s — a difference of 0.01 s, smaller than the run-to-run spread. The folklore is that an anchor lets the engine stop after the first position; whatever this implementation does, it does not show up at 400,000 records. Anchor for correctness, which is a real and frequent reason, and never for speed.
index() removes nearly all of a regex's cost. 0.93 s against 1.12 s — the regex premium drops from +0.26 to +0.07, so roughly three quarters of it disappears. For a fixed string that is a free improvement, and Chapter 14.3 gave the independent correctness argument: index() takes no escaping and cannot become a character class or a fatal pattern.
gsub on every record is the most expensive thing in the table. 1.43 s, +0.57 over the baseline, 1.66× the whole program. Guarding it with the pattern it is about to substitute brings it to 1.23 s and recovers roughly a third of the cost — and the guard is the same /u_mod/ the sub would have matched anyway, so it costs nothing to write.
An array is cheap even at 400,000 keys. 97 keys cost +0.23, and 400,000 keys cost +0.34. Four thousand times the keys for 1.5× the cost. Memory, not time, is the constraint on a large array, and Part III's grouping scripts — which hold at most a few thousand keys — are nowhere near either limit.
5. The Whole Table Spans 1.66×
Read sections 3 and 4 as one range. The fastest variant is 0.86 s and the slowest is 1.43 s.
That is the most useful number in the chapter. Every in-program optimisation available — replacing regexes with index(), hoisting field references, removing an array, swapping printf for print, anchoring, not anchoring — lives inside a 1.66× band, and most of it inside a 1.1× band. A program carefully optimised against this table is at best a little under half a second faster on 24 MB.
So before optimising an awk program, the question to ask is not "which construct is faster" but "is the program the thing that is slow?"
6. The Levers That Work
| comparison | median | ratio |
|---|---|---|
rare pattern (800 of 400,000) — awk alone | 1.12 s | — |
same — grep UVM_ERROR big.log | awk | 0.45 s | 2.49× |
grep -c UVM_ERROR big.log alone | 0.44 s | — |
common pattern (395,000 of 400,000) — awk alone | 0.96 s | — |
same — grep | awk | 0.91 s | 1.05× |
| one pass, two tallies | 1.41 s | — |
| two separate passes over the file | 2.45 s | 1.74× |
awk '{ print $2 }' | sort -u | wc -l | 1.41 s | — |
| awk does its own dedup with an array | 1.24 s | 1.14× |
The last row of the table is worth a note of its own. Doing the dedup inside awk instead of piping to sort -u is 1.14× faster, which is marginal — but it also removes a process and makes the program's output deterministic without depending on sort's locale. Chapter 7.7 measured that for (k in a) has no defined order, so an awk-internal dedup still needs an explicit ordering step if the output order matters; the speed is not the reason to choose it.
7. Comments, and Where They Can Go
A single-quoted inline program can carry comments. This is worth demonstrating because a great deal of awk is written without them on the assumption that it cannot.
cat > area.rpt <<'EOF'
u_alu 1024.500 3094 clk_core
u_ctrl 512.250 1180 clk_core
u_io 256.125 402 clk_io
EOF
awk '
# sum the area column
{ s += $2 }
END { printf " total area: %.3f\n", s }
' area.rpt total area: 1792.875A # runs to the end of the line inside the single-quoted string exactly as it does in a -f file. The only thing to be careful of is that the program now contains newlines, which is fine inside quotes and is what makes it readable.
Past roughly ten lines, awk -f prog.awk is better anyway — it is diffable, testable on its own, and does not have to survive a shell. Every script in Part III that exceeded ten lines was written that way.
8. Named Field Indices, and What They Do Not Fix
The standard remedy for $2 and $4 scattered through a program is to name them once:
awk 'BEGIN { AREA = 2; CLOCK = 4 }
{ c[$CLOCK] += $AREA }
END { n = split("clk_core clk_io", o, " ")
for (i = 1; i <= n; i++) printf " %-10s %9.3f\n", o[i], c[o[i]] }' area.rpt clk_core 1536.750
clk_io 256.125$CLOCK works because awk evaluates the expression after $, so $CLOCK is $4. The program reads as prose and there is one place to change if the layout changes.
Now the layout changes. A tool revision inserts a column:
awk '{ printf "%s %s %s rev9 %s\n", $1, $2, $3, $4 }' area.rpt > area2.rpt
head -1 area2.rpt | sed 's/^/ /'
printf " CLOCK=4 on the new layout:\n"
awk 'BEGIN { AREA = 2; CLOCK = 4 }
{ c[$CLOCK] += $AREA }
END { n = split("clk_core clk_io rev9", o, " ")
for (i = 1; i <= n; i++) if (o[i] in c) printf " %-10s %9.3f\n", o[i], c[o[i]] }' area2.rpt
printf " CLOCK=5 on the new layout:\n"
awk 'BEGIN { AREA = 2; CLOCK = 5 }
{ c[$CLOCK] += $AREA }
END { n = split("clk_core clk_io", o, " ")
for (i = 1; i <= n; i++) if (o[i] in c) printf " %-10s %9.3f\n", o[i], c[o[i]] }' area2.rpt u_alu 1024.500 3094 rev9 clk_core
CLOCK=4 on the new layout:
rev9 1792.875
CLOCK=5 on the new layout:
clk_core 1536.750
clk_io 256.1259. A Program That States Its Contract
# grouped.awk — sum area per clock domain.
#
# INPUT CONTRACT: four whitespace-separated fields per record,
# 1 module 2 area 3 cells 4 clock
# Record 1 is checked against WANT_NF and the program REFUSES rather than
# producing a plausible wrong grouping -- section 8 showed that an inserted
# column makes this program print one group holding the correct grand total,
# which is indistinguishable from a correct single-clock report.
BEGIN { WANT_NF = 4; AREA = 2; CLOCK = 4 }
NR == 1 && NF != WANT_NF {
printf "CONTRACT: expected %d fields, record 1 has %d -- refusing\n", WANT_NF, NF > "/dev/stderr"
bail = 1
exit 2
}
{ c[$CLOCK] += $AREA; kept++ }
END {
if (bail) exit 2
printf " kept=%d groups=", kept
n = split("clk_core clk_io", o, " ")
for (i = 1; i <= n; i++) if (o[i] in c) printf "[%s %.3f]", o[i], c[o[i]]
print ""
}for f in area.rpt area2.rpt; do
awk -f grouped.awk "$f" > o.txt 2> e.txt; rc=$?
printf " %-10s rc=%s stdout=[%s] stderr=[%s]\n" "$f" "$rc" "$(tr -d '\n' < o.txt | sed 's/^ *//')" "$(tr -d '\n' < e.txt)"
done area.rpt rc=0 stdout=[kept=3 groups=[clk_core 1536.750][clk_io 256.125]] stderr=[]
area2.rpt rc=2 stdout=[] stderr=[CONTRACT: expected 4 fields, record 1 has 5 -- refusing]Four properties of that guard are deliberate:
- It checks record 1 only. One comparison for the whole run, so it costs nothing measurable — and if record 1 has the expected shape, a later short record is a different fault, which Chapter 14.4's per-row accounting handles.
- It writes to
/dev/stderr. The refusal does not land in the data stream, so a caller that captures stdout gets an empty result rather than a diagnostic parsed as a row. - It exits 2, not 1. Chapter 12.6's grading: 2 means nothing can be concluded. A caller can distinguish "no groups in this report" from "I cannot read this report".
exitin a rule still runsEND, which is why thebailflag exists. Without it,ENDwould printkept=0 groups=and the exit code would be overwritten byENDcompleting normally.
That last point is the one that gets written wrong most often, and the output above is the proof it is handled: stdout=[] for the refused file, not stdout=[kept=0 groups=].
10. What Is Not Portable
Three constructs that work — or appear to work — here and should not be relied on.
awk 'BEGIN { a["x"]; a["y"]; printf " length(array) : %d\n", length(a) }'
awk 'BEGIN {
PROCINFO["sorted_in"] = "@ind_str_asc"
split("zeta alpha mike bravo yankee charlie xray delta", w, " ")
for (i in w) a[w[i]] = i
for (k in a) o = o " " k
printf " with PROCINFO set : %s\n", substr(o, 2) }'
awk 'BEGIN {
split("zeta alpha mike bravo yankee charlie xray delta", w, " ")
for (i in w) a[w[i]] = i
for (k in a) o = o " " k
printf " without PROCINFO : %s\n", substr(o, 2) }'
awk --posix 'BEGIN { print " --posix: program ran" }' 2> e2.txt
printf " --posix stderr : %s\n" "$(head -1 e2.txt)" length(array) : 2
with PROCINFO set : xray zeta charlie bravo alpha delta yankee mike
without PROCINFO : xray zeta charlie bravo alpha delta yankee mike
--posix: program ran
--posix stderr : awk: unknown option --posix ignoredlength(array) works and is not POSIX. It returns the element count here. POSIX specifies length for strings only, so a script relying on it is relying on an extension — count as you insert instead, which Chapter 14.4's Exercise 2 did with if (!(u in seen)) { seen[u]; k++ }.
PROCINFO["sorted_in"] is inert. Eight keys, chosen so that a sorted order would be obvious, give the identical order with the assignment and without it — and that order is not sorted. The assignment is accepted silently, because PROCINFO is just an array name here, so the program looks like it imposes an order and does not. Chapter 7.7's explicit sort is the portable answer.
--posix is not a mode here. It prints awk: unknown option --posix ignored on stderr and runs the program anyway, exiting 0. A script that uses it to opt into strict behaviour gets no strictness and no failure — the warning is the only signal, and it goes to a stream that scripts routinely discard.
The general rule: this awk's extensions are silent, and so are its refusals to implement someone else's. Neither length(array) succeeding nor PROCINFO being accepted tells you anything about whether the construct does what it means elsewhere. Testing it is the only way to know, which is why Chapter 8.6 reported the registry's --posix example as unusable rather than describing it.
11. Common Mistakes
Optimising the program before measuring the pipeline. The whole in-program table spans 1.66×; a selective prefilter gave 2.49×.
Anchoring a pattern for speed. /^UVM_ERROR/ and /UVM_ERROR/ differed by 0.01 s. Anchor for correctness.
Avoiding $NF because it sounds expensive. +0.04 s on 400,000 records. Fields split lazily.
Hoisting arithmetic out of the main rule. +0.02 s for 400,000 conversions and additions.
An unguarded gsub. The most expensive construct measured, +0.57 s. Guard it with the pattern it substitutes.
A regex where a fixed string was meant. index() recovered three quarters of the regex premium, and Chapter 14.3 gave the correctness reason.
Two passes over one file. 2.45 s against 1.41 s. Merge the rules into one program.
Adding a grep prefilter unconditionally. It gave 1.05× on a pattern matching 98.75% of records — overhead with no benefit.
A single timing run. The cold pass was ~0.15 s slower than every later one, which is larger than most of the effects in the table.
Believing a named field constant protects against a layout change. It made the fix one line; it did not prevent a collapsed grouping that printed the correct grand total.
Omitting an input-contract check. It is one comparison on record 1 and it is the only thing that turns section 8's silent wrong answer into a refusal.
exit in a rule without a flag. END still runs, prints an empty result and resets the status.
Relying on length(array) or PROCINFO["sorted_in"]. One is a non-POSIX extension; the other is accepted and does nothing.
12. Exercises
Exercise 1 — Comment an inline program
awk '
# area is field 2; the clock domain is field 4
{ c[$4] += $2 }
END {
n = split("clk_core clk_io", o, " ") # fixed order, see 7.7
for (i = 1; i <= n; i++) printf " %-10s %9.3f\n", o[i], c[o[i]]
}
' area.rpt clk_core 1536.750
clk_io 256.125Exercise 2 — Merge two passes into one
awk '/clk_core/ { a += $2 } END { printf " pass 1: clk_core %.3f\n", a }' area.rpt
awk '/clk_io/ { a += $2 } END { printf " pass 2: clk_io %.3f\n", a }' area.rpt
awk '/clk_core/ { a += $2 } /clk_io/ { b += $2 }
END { printf " one pass: clk_core %.3f clk_io %.3f\n", a, b }' area.rpt pass 1: clk_core 1536.750
pass 2: clk_io 256.125
one pass: clk_core 1536.750 clk_io 256.125Identical figures, one read of the file instead of two. On the 24.5 MB log that was 1.41 s against 2.45 s; on a three-line report it is indistinguishable, which is the point — the structural choice is the same at both scales and only one of them makes it visible.
Exercise 3 — Debug a grouping that collapsed
awk 'BEGIN { AREA = 2; CLOCK = 4 } { c[$CLOCK] += $AREA; k++ }
END { printf " records=%d distinct-keys=", k
n = 0; for (x in c) n++
printf "%d\n", n }' area2.rpt
awk 'BEGIN { AREA = 2; CLOCK = 4 } { c[$CLOCK] += $AREA }
END { for (x in c) printf " key=[%s] sum=%.3f\n", x, c[x] }' area2.rpt
awk 'NR == 1 { printf " record 1 has NF=%d; field 4 is [%s], field 5 is [%s]\n", NF, $4, $5 }' area2.rpt records=3 distinct-keys=1
key=[rev9] sum=1792.875
record 1 has NF=5; field 4 is [rev9], field 5 is [clk_core]Three commands, and together they close the case in the order a diagnosis should go.
distinct-keys=1 from three records is the symptom, and it is the number the collapsed report does not show: the output in section 8 was one well-formed line, and one line is exactly what a single-domain design produces. Counting the keys separates "one domain" from "one key".
key=[rev9] sum=1792.875 names the key. rev9 is not a clock, so the grouping field is not holding clocks.
NF=5, field 4 is rev9, field 5 is clk_core is the cause. The record gained a field and everything after position 3 shifted right by one.
Note that the first command counts keys with for (x in c) n++ rather than length(c): section 10 measures that length(array) works here and is not POSIX. The second command's for (x in c) is unordered, which is acceptable only because there is exactly one key — and that is what the first command established. Printing an unordered listing is safe when you have already counted it to one.
Exercise 4 turns this diagnosis into a refusal.
Exercise 4 — Make the contract explicit
awk -f grouped.awk area.rpt > o.txt 2> e.txt
printf " area.rpt rc=%s out=[%s]\n" "$?" "$(tr -d '\n' < o.txt | sed 's/^ *//')"
awk -f grouped.awk area2.rpt > o.txt 2> e.txt
printf " area2.rpt rc=%s out=[%s] err=[%s]\n" "$?" "$(tr -d '\n' < o.txt)" "$(tr -d '\n' < e.txt)" area.rpt rc=0 out=[kept=3 groups=[clk_core 1536.750][clk_io 256.125]]
area2.rpt rc=2 out=[] err=[CONTRACT: expected 4 fields, record 1 has 5 -- refusing]rc=2 and an empty stdout. The plausible wrong answer from Exercise 3 is now unreachable.
Exercise 5 — Engineering application
#!/bin/sh
# awkreview.sh — mechanical review checks for an awk program file.
#
# Usage: sh awkreview.sh <prog.awk>
#
# Reports findings and exits 1 if any fired, 0 if none did. Every check is
# something measured in Module 14, and each prints the chapter that found it.
# This is a LINTER, not a judge: a finding is a question, not a defect.
f=$1
[ -r "$f" ] || { echo "UNREADABLE: $f" >&2; exit 2; }
awk -v F="$f" '
# 14.4 section 2 -- a string comparison on a CRLF report
/\$[0-9A-Za-z_]+ *== *"/ && !seen_cr { needs_cr = 1 }
/sub\(\/\\r\$\// { seen_cr = 1; needs_cr = 0 }
# 14.5 section 4 -- an unguarded gsub in the main rule
/^[ \t]*\{[^}]*gsub\(/ { n["unguarded-gsub (14.5 s4: +0.57 s)"]++ }
# 14.3 section 4 -- an unescaped bracket in a pattern
/\/[^\/]*[^\\]\[[0-9]+\][^\/]*\// { n["bracket-is-a-char-class (14.3 s4)"]++ }
# 14.5 section 10 -- constructs that are not portable
/length\(([A-Za-z_][A-Za-z0-9_]*)\)/ && !/length\(\$/ { n["length(array) is not POSIX (14.5 s10)"]++ }
/PROCINFO/ { n["PROCINFO is inert here (14.5 s10)"]++ }
# 14.5 section 9 -- exit in a rule with no END guard
/[^A-Za-z_]exit [0-9]/ { saw_exit = 1 }
/^END/ { saw_end = 1 }
/if *\(bail/ { saw_guard = 1 }
# 7.7 -- an unordered listing in END
/for *\([A-Za-z_][A-Za-z0-9_]* in / { saw_forin = 1 }
/split\(".*", *[A-Za-z_]/ { saw_order = 1 }
END {
if (needs_cr) n["string compare with no CR strip (14.4 s2)"]++
if (saw_exit && saw_end && !saw_guard) n["exit in a rule, END unguarded (14.5 s9)"]++
if (saw_forin && !saw_order) n["for-in with no imposed order (7.7)"]++
c = 0
for (k in n) c++
if (c == 0) { printf "%s: no findings\n", F; exit 0 }
printf "%s: %d finding(s)\n", F, c
# fixed order: emit by the chapter tag embedded in the key
split("string compare with no CR strip (14.4 s2)|" \
"bracket-is-a-char-class (14.3 s4)|" \
"unguarded-gsub (14.5 s4: +0.57 s)|" \
"exit in a rule, END unguarded (14.5 s9)|" \
"length(array) is not POSIX (14.5 s10)|" \
"PROCINFO is inert here (14.5 s10)|" \
"for-in with no imposed order (7.7)", ord, "|")
for (i = 1; i <= 7; i++) if (ord[i] in n) printf " %s\n", ord[i]
exit 1
}
' "$f"sh awkreview.sh grouped.awk > r.txt 2>&1; printf " rc=%s\n" "$?"; sed 's/^/ /' r.txt
cat > bad.awk <<'AWK'
{ gsub(/u_mod/, "M") }
$1 == "PASS" { p++ }
/sum_reg[3]/ { hit++ }
{ a[$2] = 1 }
END { printf "%d %d %d\n", p, hit, length(a); for (k in a) print k; exit 1 }
AWK
sh awkreview.sh bad.awk > r2.txt 2>&1; printf " rc=%s\n" "$?"; sed 's/^/ /' r2.txt rc=0
grouped.awk: no findings
rc=1
bad.awk: 6 finding(s)
string compare with no CR strip (14.4 s2)
bracket-is-a-char-class (14.3 s4)
unguarded-gsub (14.5 s4: +0.57 s)
exit in a rule, END unguarded (14.5 s9)
length(array) is not POSIX (14.5 s10)
for-in with no imposed order (7.7)Six findings on a five-line program, and grouped.awk — written to this module's conclusions — comes back clean with rc=0.
Every one of the six is a real property of bad.awk, and that is the point of running it on a specimen built to have them rather than on a script that happens to be at hand. Chapter 14.6 is about the checks that cannot be mechanised; this script covers only the ones that can.
Exercise 6 — Challenge: prove the linter can fail
A check that never fires is indistinguishable from a check that passes. Show that each of the seven rules can produce a finding, and find the one that cannot.
printf '$1 == "PASS" { n++ }\n' > c1.awk
printf '/sum_reg[3]/ { n++ }\n' > c2.awk
printf '{ gsub(/x/, "y") }\n' > c3.awk
printf '{ exit 2 }\nEND { print "x" }\n' > c4.awk
printf 'END { print length(a) }\n' > c5.awk
printf 'BEGIN { PROCINFO["sorted_in"] = "x" }\n' > c6.awk
printf 'END { for (k in a) print k }\n' > c7.awk
for i in 1 2 3 4 5 6 7; do
printf " c%s.awk -> %s\n" "$i" "$(sh awkreview.sh "c$i.awk" 2>&1 | sed -n '2p' | sed 's/^ *//')"
done c1.awk -> string compare with no CR strip (14.4 s2)
c2.awk -> bracket-is-a-char-class (14.3 s4)
c3.awk -> unguarded-gsub (14.5 s4: +0.57 s)
c4.awk -> exit in a rule, END unguarded (14.5 s9)
c5.awk -> length(array) is not POSIX (14.5 s10)
c6.awk -> PROCINFO is inert here (14.5 s10)
c7.awk -> for-in with no imposed order (7.7)Seven for seven. Each rule fired on a one-line program written to trigger exactly it, so none of the seven is dead code.
A suite is not proven by passing; it is proven by each rule being shown able to fail. A rule that never fires and a rule that always passes produce the same output, so a clean run says nothing until each rule has been seen to fire once. That is why bad.awk in Exercise 5 was written to contain all six findings rather than borrowing a script that happened to be available, and why these seven one-line specimens exist at all.
Two honest limits remain, and they are not fixed by more rules of the same kind:
- A regex-based linter reads text, not meaning.
c1.awkhas no CRLF problem — it will run against a clean report perfectly. The finding says "this program compares a string and never strips a CR", which is a question about an input the linter cannot see. - A pattern can be defeated by formatting. Writing the
gsubacross two lines, or the comparison with the string on the left, puts it out of reach of these patterns — and a reformatting that silences a finding has changed nothing about the program. Any checker built from regular expressions over source text inherits this, which is the limit Chapter 14.6 exists to cover.
13. Summary
- The whole in-program optimisation table spans 1.66× — 0.86 s to 1.43 s on 400,000 records. Measure the pipeline before tuning the program.
- Field splitting is free.
$NFcost +0.04 s and arithmetic on a field +0.02 s over a do-nothing baseline, because awk splits lazily. - Anchoring a regex buys nothing — 1.11 s against 1.12 s. Anchor for correctness only.
$1 == "string"is the cheapest selective test at +0.03, cheaper than any regex, and more precise.index()removes about three quarters of a regex's cost for a fixed string, and needs no escaping.- An unguarded
gsubis the most expensive construct measured, +0.57 s; guarding it recovers a third. - An array is cheap — 4,000× the keys for 1.5× the cost. Memory is the limit, not time.
- Read less: a selective prefilter gave 2.49×, and nothing (1.05×) on a pattern matching 98.75% of records. Selectivity is the rule.
- Read once: merging two passes gave 1.74×, the largest in-program lever here.
- Median of five, with an external
time, and run the table more than once — the cold pass was 0.15 s slower than every later one. - An inline single-quoted program can carry
#comments; past ten lines use-f. $CLOCKworks, and a named constant did not prevent a collapsed grouping that printed the correct grand total under the keyrev9. It bought repairability, not robustness.- An input-contract check on record 1 is one comparison and is what turns that silent wrong answer into
rc=2with empty stdout. exitin a rule still runsEND— guard it with a flag or the refusal prints a result and resets the status.length(array)is a non-POSIX extension,PROCINFO["sorted_in"]is inert (identical unsorted order with and without), and--posixwarns on stderr and runs anyway.- Each linter rule must be shown able to fail — seven of seven fired on purpose-built one-line specimens.
- A regex linter reads text, not meaning, and can be defeated by reformatting.
Chapter 14.6 — Engineering Review Checklist is the other half of that last point: the review questions that no checker can ask, organised as the order to ask them in.
Where this fits
Part of the awk curriculum.
