Repository: benhoyt/goawk
Pull request #88 (2022-01-21 05:01:05)

Compiler and virtual machine interpreter
This PR changes the simple tree-walk interpreter to a bytecode compiler and interpreter (really "word code" as instructions are 32 bits). It's significantly more complex, and a little bit faster. I'm still not sure the trade-off was worth it! It speeds up "CPU" type operations like increment, variable access, assignment, but of course it doesn't speed up I/O or Go's fairly slow regex engine. But it was a fun journey, and I think I'm going to merge it.

Microbenchmarks are 18% faster:

```
name                    old time/op  new time/op  delta
NativeFunc-8            10.7µs ± 0%  10.8µs ± 0%   +0.67%  (p=0.008 n=5+5)
BuiltinGsub-8           16.2µs ± 0%  16.2µs ± 0%   +0.36%  (p=0.008 n=5+5)
BuiltinGsubAmpersand-8  16.2µs ± 0%  16.2µs ± 0%   +0.29%  (p=0.008 n=5+5)
BuiltinSub-8            13.6µs ± 0%  13.6µs ± 0%     ~     (p=1.000 n=5+5)
BuiltinSubAmpersand-8   13.5µs ± 0%  13.6µs ± 0%     ~     (p=0.095 n=5+5)
SimplePattern-8          133ns ± 1%   134ns ± 0%     ~     (p=0.063 n=5+5)
ConcatLarge-8           8.43ms ± 1%  8.35ms ± 2%     ~     (p=0.690 n=5+5)
BuiltinSplitRegex-8     87.9µs ± 0%  87.7µs ± 0%   -0.21%  (p=0.016 n=5+5)
BuiltinSplitSpace-8     35.4µs ± 0%  35.1µs ± 0%   -0.70%  (p=0.008 n=5+5)
GetField-8               445ns ± 1%   435ns ± 2%   -2.42%  (p=0.008 n=5+5)
FuncCall-8              2.84µs ± 0%  2.76µs ± 2%   -2.65%  (p=0.008 n=5+5)
BuiltinSprintf-8        9.67µs ± 0%  9.23µs ± 0%   -4.58%  (p=0.008 n=5+5)
RecursiveFunc-8         15.7µs ± 0%  14.9µs ± 0%   -4.95%  (p=0.008 n=5+5)
ConcatSmall-8            735ns ± 0%   691ns ± 1%   -5.98%  (p=0.008 n=5+5)
BuiltinMatch-8          2.91µs ± 0%  2.71µs ± 1%   -7.02%  (p=0.008 n=5+5)
BuiltinIndex-8          1.23µs ± 1%  1.11µs ± 1%   -9.51%  (p=0.008 n=5+5)
RegexMatch-8            1.24µs ± 1%  1.11µs ± 4%  -10.07%  (p=0.008 n=5+5)
SetField-8               905ns ± 0%   810ns ± 0%  -10.45%  (p=0.008 n=5+5)
ForInLoop-8             2.04µs ± 2%  1.78µs ± 4%  -12.86%  (p=0.008 n=5+5)
ArrayOperations-8        657ns ± 0%   565ns ± 0%  -13.94%  (p=0.008 n=5+5)
BinaryOperators-8        493ns ± 0%   413ns ± 0%  -16.15%  (p=0.008 n=5+5)
BuiltinSubstr-8          975ns ± 0%   765ns ± 0%  -21.50%  (p=0.016 n=4+5)
Comparisons-8            417ns ± 0%   321ns ± 0%  -22.98%  (p=0.016 n=5+4)
SimpleBuiltins-8        1.00µs ± 0%  0.75µs ± 0%  -25.61%  (p=0.008 n=5+5)
CondExpr-8               203ns ± 0%   151ns ± 0%  -25.62%  (p=0.016 n=4+5)
BuiltinLength-8          607ns ± 0%   429ns ± 0%  -29.34%  (p=0.008 n=5+5)
IfStatement-8            219ns ± 0%   152ns ± 0%  -30.65%  (p=0.008 n=5+5)
AugAssign-8             1.50µs ± 0%  0.98µs ± 0%  -34.74%  (p=0.008 n=5+5)
LocalVars-8              479ns ± 0%   300ns ± 2%  -37.32%  (p=0.008 n=5+5)
Assign-8                 446ns ± 0%   261ns ± 0%  -41.55%  (p=0.008 n=5+5)
ForLoop-8               4.34µs ± 0%  2.50µs ± 0%  -42.39%  (p=0.008 n=5+5)
GlobalVars-8             468ns ± 0%   269ns ± 1%  -42.48%  (p=0.008 n=5+5)
IncrDecr-8               448ns ± 0%   148ns ± 0%  -66.87%  (p=0.016 n=5+4)
[Geo mean]              2.36µs       1.94µs       -17.90%
```

More real-world benchmarks are 13% faster:

Test      | goawk |  orig |   awk |  gawk |  mawk
--------- | ----- | ----- | ----- | ----- | -----
tt.01     |  2.02 |  1.91 |  1.00 |  1.66 |  2.29
tt.02     |  1.59 |  1.60 |  1.00 |  1.77 |  2.20
tt.02a    |  1.54 |  1.56 |  1.00 |  1.73 |  2.05
tt.03     |  1.32 |  1.27 |  1.00 |  3.85 |  1.83
tt.03a    |  1.29 |  1.26 |  1.00 |  4.08 |  1.79
tt.04     |  0.97 |  0.80 |  1.00 |  1.26 |  2.74
tt.05     |  0.95 |  0.88 |  1.00 |  1.61 |  2.26
tt.06     |  1.39 |  1.35 |  1.00 |  2.53 |  1.97
tt.07     |  1.24 |  1.18 |  1.00 |  1.46 |  1.71
tt.08     |  1.97 |  1.98 |  1.00 |  1.13 |  2.70
tt.09     |  2.13 |  2.13 |  1.00 |  2.41 |  5.01
tt.10     |  0.34 |  0.34 |  1.00 |  1.45 |  3.40
tt.10a    |  0.32 |  0.34 |  1.00 |  1.30 |  3.08
tt.11     |  2.20 |  2.13 |  1.00 |  1.15 |  3.68
tt.12     |  1.21 |  1.19 |  1.00 |  1.70 |  1.78
tt.13     |  3.14 |  2.62 |  1.00 |  3.11 |  5.92
tt.13a    |  2.25 |  1.80 |  1.00 |  1.86 |  4.85
tt.14     |  1.17 |  1.09 |  1.00 |  0.64 |  1.56
tt.15     |  0.62 |  0.61 |  1.00 |  0.96 |  2.21
tt.16     |  1.47 |  1.21 |  1.00 |  1.27 |  2.12
tt.big    |  1.62 |  1.41 |  1.00 |  1.83 |  3.82
tt.x1     |  2.25 |  1.62 |  1.00 |  1.34 |  3.44
tt.x2     |  1.76 |  1.03 |  1.00 |  1.15 |  2.68
--------- | ----- | ----- | ----- | ----- | -----
**Geo mean**  | **1.45** | **1.28** | **1.00** | **1.86** | **2.32**



Patch:
diff --git a/benchmark_awks.py b/benchmark_awks.py
index 6ac8255..77aa934 100755
--- a/benchmark_awks.py
+++ b/benchmark_awks.py
@@ -35,7 +35,7 @@ TESTS_TO_MEAN = [
     'tt.x2',
 ]
 MEAN_TESTS = []
-NUM_RUNS = 3
+NUM_RUNS = 6
 MIN_TIME = 0.5
 PROGRAM_GLOB = 'testdata/tt.*'
 
@@ -93,7 +93,8 @@ for program in programs:
             times.append(elapsed)
             if status != 0:
                 print('ERROR status {} from cmd: {}'.format(status, cmdline), file=sys.stderr)
-        awk_times.append(min(times))
+        min_time = min(sorted(times)[1:])
+        awk_times.append(min_time)
 
     # Normalize to One True AWK time = 1.0
     norm_time = awk_times[NORM_INDEX]
diff --git a/benchmarks.txt b/benchmarks.txt
index a81874c..c2dbc5d 100644
--- a/benchmarks.txt
+++ b/benchmarks.txt
@@ -2,170 +2,170 @@ goos: linux
 goarch: amd64
 pkg: github.com/benhoyt/goawk/interp
 cpu: Intel(R) Core(TM) i7-6700HQ CPU @ 2.60GHz
-BenchmarkGlobalVars-8             	 2516854	       469.2 ns/op
-BenchmarkGlobalVars-8             	 2566245	       468.0 ns/op
-BenchmarkGlobalVars-8             	 2543346	       467.3 ns/op
-BenchmarkGlobalVars-8             	 2543233	       468.5 ns/op
-BenchmarkGlobalVars-8             	 2550079	       467.2 ns/op
-BenchmarkLocalVars-8              	 2500323	       481.1 ns/op
-BenchmarkLocalVars-8              	 2491880	       477.9 ns/op
-BenchmarkLocalVars-8              	 2499526	       478.5 ns/op
-BenchmarkLocalVars-8              	 2492163	       479.9 ns/op
-BenchmarkLocalVars-8              	 2486038	       477.9 ns/op
-BenchmarkIncrDecr-8               	 2661453	       447.9 ns/op
-BenchmarkIncrDecr-8               	 2660907	       447.5 ns/op
-BenchmarkIncrDecr-8               	 2642314	       448.4 ns/op
-BenchmarkIncrDecr-8               	 2666538	       447.9 ns/op
-BenchmarkIncrDecr-8               	 2665522	       449.3 ns/op
-BenchmarkSimpleBuiltins-8         	 1000000	      1001 ns/op
-BenchmarkSimpleBuiltins-8         	 1000000	      1007 ns/op
-BenchmarkSimpleBuiltins-8         	 1000000	      1002 ns/op
-BenchmarkSimpleBuiltins-8         	 1000000	      1006 ns/op
-BenchmarkSimpleBuiltins-8         	 1000000	      1001 ns/op
-BenchmarkBuiltinMatch-8           	  388563	      2912 ns/op
-BenchmarkBuiltinMatch-8           	  391690	      2913 ns/op
-BenchmarkBuiltinMatch-8           	  382140	      2907 ns/op
-BenchmarkBuiltinMatch-8           	  370896	      2902 ns/op
-BenchmarkBuiltinMatch-8           	  377797	      2913 ns/op
-BenchmarkBuiltinLength-8          	 1943457	       606.8 ns/op
-BenchmarkBuiltinLength-8          	 1968284	       607.0 ns/op
-BenchmarkBuiltinLength-8          	 1964948	       606.6 ns/op
-BenchmarkBuiltinLength-8          	 1961205	       606.3 ns/op
-BenchmarkBuiltinLength-8          	 1961919	       608.2 ns/op
-BenchmarkBuiltinIndex-8           	  937009	      1223 ns/op
-BenchmarkBuiltinIndex-8           	  952438	      1223 ns/op
-BenchmarkBuiltinIndex-8           	  957181	      1237 ns/op
-BenchmarkBuiltinIndex-8           	  922442	      1223 ns/op
-BenchmarkBuiltinIndex-8           	  926184	      1227 ns/op
-BenchmarkBuiltinSubstr-8          	 1223923	       982.5 ns/op
-BenchmarkBuiltinSubstr-8          	 1226154	       974.9 ns/op
-BenchmarkBuiltinSubstr-8          	 1226338	       974.7 ns/op
-BenchmarkBuiltinSubstr-8          	 1225555	       975.2 ns/op
-BenchmarkBuiltinSubstr-8          	 1225071	       975.5 ns/op
-BenchmarkBuiltinSplitSpace-8      	   32988	     35372 ns/op
-BenchmarkBuiltinSplitSpace-8      	   33074	     35425 ns/op
-BenchmarkBuiltinSplitSpace-8      	   33206	     35392 ns/op
-BenchmarkBuiltinSplitSpace-8      	   32917	     35455 ns/op
-BenchmarkBuiltinSplitSpace-8      	   32803	     35315 ns/op
-BenchmarkBuiltinSplitRegex-8      	   13594	     87864 ns/op
-BenchmarkBuiltinSplitRegex-8      	   13610	     87743 ns/op
-BenchmarkBuiltinSplitRegex-8      	   13512	     88031 ns/op
-BenchmarkBuiltinSplitRegex-8      	   13579	     87864 ns/op
-BenchmarkBuiltinSplitRegex-8      	   13567	     87795 ns/op
-BenchmarkBuiltinSub-8             	   84638	     13567 ns/op
-BenchmarkBuiltinSub-8             	   84108	     13588 ns/op
-BenchmarkBuiltinSub-8             	   83781	     13581 ns/op
-BenchmarkBuiltinSub-8             	   84374	     13596 ns/op
-BenchmarkBuiltinSub-8             	   84732	     13604 ns/op
-BenchmarkBuiltinSubAmpersand-8    	   83485	     13558 ns/op
-BenchmarkBuiltinSubAmpersand-8    	   84955	     13564 ns/op
-BenchmarkBuiltinSubAmpersand-8    	   81728	     13531 ns/op
-BenchmarkBuiltinSubAmpersand-8    	   83971	     13582 ns/op
-BenchmarkBuiltinSubAmpersand-8    	   84494	     13497 ns/op
-BenchmarkBuiltinGsub-8            	   71474	     16178 ns/op
-BenchmarkBuiltinGsub-8            	   71490	     16148 ns/op
-BenchmarkBuiltinGsub-8            	   71569	     16133 ns/op
-BenchmarkBuiltinGsub-8            	   70952	     16161 ns/op
-BenchmarkBuiltinGsub-8            	   69778	     16181 ns/op
-BenchmarkBuiltinGsubAmpersand-8   	   71228	     16164 ns/op
-BenchmarkBuiltinGsubAmpersand-8   	   71208	     16144 ns/op
-BenchmarkBuiltinGsubAmpersand-8   	   70245	     16180 ns/op
-BenchmarkBuiltinGsubAmpersand-8   	   71400	     16140 ns/op
-BenchmarkBuiltinGsubAmpersand-8   	   71577	     16169 ns/op
-BenchmarkBuiltinSprintf-8         	  116769	      9682 ns/op
-BenchmarkBuiltinSprintf-8         	  117169	      9653 ns/op
-BenchmarkBuiltinSprintf-8         	  116137	      9680 ns/op
-BenchmarkBuiltinSprintf-8         	  117202	      9669 ns/op
-BenchmarkBuiltinSprintf-8         	  115774	      9673 ns/op
-BenchmarkRecursiveFunc-8          	   74127	     15723 ns/op
-BenchmarkRecursiveFunc-8          	   74289	     15676 ns/op
-BenchmarkRecursiveFunc-8          	   73399	     15622 ns/op
-BenchmarkRecursiveFunc-8          	   73726	     15653 ns/op
-BenchmarkRecursiveFunc-8          	   73742	     15674 ns/op
-BenchmarkFuncCall-8               	  371230	      2833 ns/op
-BenchmarkFuncCall-8               	  389635	      2838 ns/op
-BenchmarkFuncCall-8               	  369360	      2829 ns/op
-BenchmarkFuncCall-8               	  380935	      2839 ns/op
-BenchmarkFuncCall-8               	  368895	      2837 ns/op
-BenchmarkNativeFunc-8             	  107110	     10766 ns/op
-BenchmarkNativeFunc-8             	  106622	     10744 ns/op
-BenchmarkNativeFunc-8             	  106888	     10729 ns/op
-BenchmarkNativeFunc-8             	  104766	     10735 ns/op
-BenchmarkNativeFunc-8             	  105458	     10715 ns/op
-BenchmarkForLoop-8                	  260619	      4353 ns/op
-BenchmarkForLoop-8                	  264759	      4340 ns/op
-BenchmarkForLoop-8                	  262053	      4335 ns/op
-BenchmarkForLoop-8                	  241664	      4330 ns/op
-BenchmarkForLoop-8                	  266877	      4335 ns/op
-BenchmarkForInLoop-8              	  590528	      2040 ns/op
-BenchmarkForInLoop-8              	  600932	      2048 ns/op
-BenchmarkForInLoop-8              	  581919	      2051 ns/op
-BenchmarkForInLoop-8              	  568269	      2063 ns/op
-BenchmarkForInLoop-8              	  587821	      1994 ns/op
-BenchmarkIfStatement-8            	 5450338	       219.4 ns/op
-BenchmarkIfStatement-8            	 5399404	       218.2 ns/op
-BenchmarkIfStatement-8            	 5405452	       218.4 ns/op
-BenchmarkIfStatement-8            	 5382660	       219.2 ns/op
-BenchmarkIfStatement-8            	 5429893	       219.2 ns/op
-BenchmarkCondExpr-8               	 5833510	       202.6 ns/op
-BenchmarkCondExpr-8               	 5832939	       202.6 ns/op
-BenchmarkCondExpr-8               	 5850146	       202.5 ns/op
-BenchmarkCondExpr-8               	 5854233	       202.6 ns/op
-BenchmarkCondExpr-8               	 5836489	       202.9 ns/op
-BenchmarkSimplePattern-8          	 9268662	       132.3 ns/op
-BenchmarkSimplePattern-8          	 9136610	       133.8 ns/op
-BenchmarkSimplePattern-8          	 9166732	       132.1 ns/op
-BenchmarkSimplePattern-8          	 9210891	       132.8 ns/op
-BenchmarkSimplePattern-8          	 9165394	       134.0 ns/op
-BenchmarkGetField-8               	 2761090	       444.3 ns/op
-BenchmarkGetField-8               	 2745423	       444.1 ns/op
-BenchmarkGetField-8               	 2761850	       449.8 ns/op
-BenchmarkGetField-8               	 2693841	       443.4 ns/op
-BenchmarkGetField-8               	 2765298	       445.7 ns/op
-BenchmarkSetField-8               	 1320168	       905.2 ns/op
-BenchmarkSetField-8               	 1320735	       905.4 ns/op
-BenchmarkSetField-8               	 1322508	       905.3 ns/op
-BenchmarkSetField-8               	 1319961	       903.6 ns/op
-BenchmarkSetField-8               	 1322865	       905.7 ns/op
-BenchmarkRegexMatch-8             	  952941	      1239 ns/op
-BenchmarkRegexMatch-8             	  913795	      1247 ns/op
-BenchmarkRegexMatch-8             	  834810	      1232 ns/op
-BenchmarkRegexMatch-8             	  933075	      1230 ns/op
-BenchmarkRegexMatch-8             	  842931	      1246 ns/op
-BenchmarkBinaryOperators-8        	 2419839	       493.1 ns/op
-BenchmarkBinaryOperators-8        	 2400955	       493.3 ns/op
-BenchmarkBinaryOperators-8        	 2421229	       492.5 ns/op
-BenchmarkBinaryOperators-8        	 2421549	       494.3 ns/op
-BenchmarkBinaryOperators-8        	 2413123	       492.4 ns/op
-BenchmarkConcatSmall-8            	 1619000	       733.8 ns/op
-BenchmarkConcatSmall-8            	 1621198	       733.9 ns/op
-BenchmarkConcatSmall-8            	 1605169	       735.8 ns/op
-BenchmarkConcatSmall-8            	 1615327	       734.3 ns/op
-BenchmarkConcatSmall-8            	 1613971	       738.1 ns/op
-BenchmarkConcatLarge-8            	     139	   8398131 ns/op
-BenchmarkConcatLarge-8            	     139	   8479159 ns/op
-BenchmarkConcatLarge-8            	     138	   8529364 ns/op
-BenchmarkConcatLarge-8            	     139	   8397705 ns/op
-BenchmarkConcatLarge-8            	     135	   8336201 ns/op
-BenchmarkComparisons-8            	 2813127	       418.9 ns/op
-BenchmarkComparisons-8            	 2858272	       416.8 ns/op
-BenchmarkComparisons-8            	 2864952	       416.1 ns/op
-BenchmarkComparisons-8            	 2868218	       416.4 ns/op
-BenchmarkComparisons-8            	 2814019	       416.5 ns/op
-BenchmarkArrayOperations-8        	 1822561	       656.0 ns/op
-BenchmarkArrayOperations-8        	 1815475	       656.1 ns/op
-BenchmarkArrayOperations-8        	 1821321	       657.5 ns/op
-BenchmarkArrayOperations-8        	 1814377	       658.0 ns/op
-BenchmarkArrayOperations-8        	 1815915	       656.6 ns/op
-BenchmarkAssign-8                 	 2679387	       445.9 ns/op
-BenchmarkAssign-8                 	 2689936	       445.1 ns/op
-BenchmarkAssign-8                 	 2667388	       447.4 ns/op
-BenchmarkAssign-8                 	 2669216	       445.4 ns/op
-BenchmarkAssign-8                 	 2668048	       446.1 ns/op
-BenchmarkAugAssign-8              	  778339	      1506 ns/op
-BenchmarkAugAssign-8              	  734612	      1503 ns/op
-BenchmarkAugAssign-8              	  777285	      1504 ns/op
-BenchmarkAugAssign-8              	  704532	      1503 ns/op
-BenchmarkAugAssign-8              	  765958	      1505 ns/op
+BenchmarkGlobalVars-8             	 4380072	       269.2 ns/op
+BenchmarkGlobalVars-8             	 4451776	       269.6 ns/op
+BenchmarkGlobalVars-8             	 4440924	       271.1 ns/op
+BenchmarkGlobalVars-8             	 4435388	       268.5 ns/op
+BenchmarkGlobalVars-8             	 4451508	       267.8 ns/op
+BenchmarkLocalVars-8              	 3954339	       303.4 ns/op
+BenchmarkLocalVars-8              	 3918230	       303.6 ns/op
+BenchmarkLocalVars-8              	 3946796	       301.0 ns/op
+BenchmarkLocalVars-8              	 3950096	       297.9 ns/op
+BenchmarkLocalVars-8              	 3940108	       295.5 ns/op
+BenchmarkIncrDecr-8               	 8025244	       148.7 ns/op
+BenchmarkIncrDecr-8               	 8023848	       148.5 ns/op
+BenchmarkIncrDecr-8               	 8036666	       148.5 ns/op
+BenchmarkIncrDecr-8               	 8052188	       148.5 ns/op
+BenchmarkIncrDecr-8               	 8055669	       148.5 ns/op
+BenchmarkSimpleBuiltins-8         	 1607059	       746.0 ns/op
+BenchmarkSimpleBuiltins-8         	 1608235	       745.8 ns/op
+BenchmarkSimpleBuiltins-8         	 1606586	       747.4 ns/op
+BenchmarkSimpleBuiltins-8         	 1603380	       746.6 ns/op
+BenchmarkSimpleBuiltins-8         	 1605854	       746.1 ns/op
+BenchmarkBuiltinMatch-8           	  430694	      2701 ns/op
+BenchmarkBuiltinMatch-8           	  446473	      2722 ns/op
+BenchmarkBuiltinMatch-8           	  443654	      2703 ns/op
+BenchmarkBuiltinMatch-8           	  448518	      2701 ns/op
+BenchmarkBuiltinMatch-8           	  439944	      2699 ns/op
+BenchmarkBuiltinLength-8          	 2764423	       428.5 ns/op
+BenchmarkBuiltinLength-8          	 2796885	       429.2 ns/op
+BenchmarkBuiltinLength-8          	 2779918	       429.3 ns/op
+BenchmarkBuiltinLength-8          	 2788358	       428.8 ns/op
+BenchmarkBuiltinLength-8          	 2768715	       428.6 ns/op
+BenchmarkBuiltinIndex-8           	 1000000	      1108 ns/op
+BenchmarkBuiltinIndex-8           	 1000000	      1118 ns/op
+BenchmarkBuiltinIndex-8           	 1000000	      1107 ns/op
+BenchmarkBuiltinIndex-8           	 1000000	      1108 ns/op
+BenchmarkBuiltinIndex-8           	 1000000	      1109 ns/op
+BenchmarkBuiltinSubstr-8          	 1569376	       763.5 ns/op
+BenchmarkBuiltinSubstr-8          	 1566152	       767.1 ns/op
+BenchmarkBuiltinSubstr-8          	 1568415	       766.2 ns/op
+BenchmarkBuiltinSubstr-8          	 1564752	       763.1 ns/op
+BenchmarkBuiltinSubstr-8          	 1568408	       767.2 ns/op
+BenchmarkBuiltinSplitSpace-8      	   33446	     35067 ns/op
+BenchmarkBuiltinSplitSpace-8      	   33061	     35137 ns/op
+BenchmarkBuiltinSplitSpace-8      	   33561	     35153 ns/op
+BenchmarkBuiltinSplitSpace-8      	   33550	     35088 ns/op
+BenchmarkBuiltinSplitSpace-8      	   33504	     35272 ns/op
+BenchmarkBuiltinSplitRegex-8      	   13611	     87593 ns/op
+BenchmarkBuiltinSplitRegex-8      	   13636	     87752 ns/op
+BenchmarkBuiltinSplitRegex-8      	   13578	     87679 ns/op
+BenchmarkBuiltinSplitRegex-8      	   13640	     87619 ns/op
+BenchmarkBuiltinSplitRegex-8      	   13611	     87725 ns/op
+BenchmarkBuiltinSub-8             	   85480	     13586 ns/op
+BenchmarkBuiltinSub-8             	   84834	     13579 ns/op
+BenchmarkBuiltinSub-8             	   84314	     13592 ns/op
+BenchmarkBuiltinSub-8             	   84642	     13616 ns/op
+BenchmarkBuiltinSub-8             	   84988	     13569 ns/op
+BenchmarkBuiltinSubAmpersand-8    	   85504	     13618 ns/op
+BenchmarkBuiltinSubAmpersand-8    	   85941	     13600 ns/op
+BenchmarkBuiltinSubAmpersand-8    	   85443	     13572 ns/op
+BenchmarkBuiltinSubAmpersand-8    	   85892	     13551 ns/op
+BenchmarkBuiltinSubAmpersand-8    	   85430	     13598 ns/op
+BenchmarkBuiltinGsub-8            	   71433	     16261 ns/op
+BenchmarkBuiltinGsub-8            	   71912	     16183 ns/op
+BenchmarkBuiltinGsub-8            	   71548	     16198 ns/op
+BenchmarkBuiltinGsub-8            	   71412	     16218 ns/op
+BenchmarkBuiltinGsub-8            	   71828	     16230 ns/op
+BenchmarkBuiltinGsubAmpersand-8   	   72127	     16183 ns/op
+BenchmarkBuiltinGsubAmpersand-8   	   71512	     16208 ns/op
+BenchmarkBuiltinGsubAmpersand-8   	   72012	     16224 ns/op
+BenchmarkBuiltinGsubAmpersand-8   	   71353	     16208 ns/op
+BenchmarkBuiltinGsubAmpersand-8   	   71920	     16205 ns/op
+BenchmarkBuiltinSprintf-8         	  125295	      9235 ns/op
+BenchmarkBuiltinSprintf-8         	  124045	      9235 ns/op
+BenchmarkBuiltinSprintf-8         	  123946	      9224 ns/op
+BenchmarkBuiltinSprintf-8         	  123730	      9226 ns/op
+BenchmarkBuiltinSprintf-8         	  123906	      9224 ns/op
+BenchmarkRecursiveFunc-8          	   77940	     14900 ns/op
+BenchmarkRecursiveFunc-8          	   78810	     14873 ns/op
+BenchmarkRecursiveFunc-8          	   78255	     14900 ns/op
+BenchmarkRecursiveFunc-8          	   77872	     14875 ns/op
+BenchmarkRecursiveFunc-8          	   78520	     14924 ns/op
+BenchmarkFuncCall-8               	  407462	      2744 ns/op
+BenchmarkFuncCall-8               	  411840	      2738 ns/op
+BenchmarkFuncCall-8               	  412160	      2739 ns/op
+BenchmarkFuncCall-8               	  414537	      2811 ns/op
+BenchmarkFuncCall-8               	  397334	      2768 ns/op
+BenchmarkNativeFunc-8             	  107394	     10827 ns/op
+BenchmarkNativeFunc-8             	  108002	     10826 ns/op
+BenchmarkNativeFunc-8             	  106399	     10812 ns/op
+BenchmarkNativeFunc-8             	  106632	     10793 ns/op
+BenchmarkNativeFunc-8             	  107889	     10792 ns/op
+BenchmarkForLoop-8                	  465045	      2511 ns/op
+BenchmarkForLoop-8                	  469138	      2497 ns/op
+BenchmarkForLoop-8                	  471015	      2495 ns/op
+BenchmarkForLoop-8                	  473204	      2498 ns/op
+BenchmarkForLoop-8                	  462315	      2496 ns/op
+BenchmarkForInLoop-8              	  656872	      1780 ns/op
+BenchmarkForInLoop-8              	  651300	      1779 ns/op
+BenchmarkForInLoop-8              	  634387	      1714 ns/op
+BenchmarkForInLoop-8              	  651572	      1793 ns/op
+BenchmarkForInLoop-8              	  626314	      1819 ns/op
+BenchmarkIfStatement-8            	 7864362	       151.7 ns/op
+BenchmarkIfStatement-8            	 7846591	       151.6 ns/op
+BenchmarkIfStatement-8            	 7835412	       151.6 ns/op
+BenchmarkIfStatement-8            	 7743740	       152.1 ns/op
+BenchmarkIfStatement-8            	 7766904	       152.0 ns/op
+BenchmarkCondExpr-8               	 7886330	       150.6 ns/op
+BenchmarkCondExpr-8               	 7801327	       150.7 ns/op
+BenchmarkCondExpr-8               	 7885614	       150.6 ns/op
+BenchmarkCondExpr-8               	 7737680	       150.8 ns/op
+BenchmarkCondExpr-8               	 7844936	       150.7 ns/op
+BenchmarkSimplePattern-8          	 9018822	       133.8 ns/op
+BenchmarkSimplePattern-8          	 9105745	       134.1 ns/op
+BenchmarkSimplePattern-8          	 9150810	       134.4 ns/op
+BenchmarkSimplePattern-8          	 9123049	       134.2 ns/op
+BenchmarkSimplePattern-8          	 9116821	       133.6 ns/op
+BenchmarkGetField-8               	 2834528	       431.0 ns/op
+BenchmarkGetField-8               	 2838696	       436.6 ns/op
+BenchmarkGetField-8               	 2849317	       430.8 ns/op
+BenchmarkGetField-8               	 2850678	       441.7 ns/op
+BenchmarkGetField-8               	 2829369	       433.3 ns/op
+BenchmarkSetField-8               	 1474214	       808.8 ns/op
+BenchmarkSetField-8               	 1451209	       810.5 ns/op
+BenchmarkSetField-8               	 1469554	       809.1 ns/op
+BenchmarkSetField-8               	 1474796	       810.9 ns/op
+BenchmarkSetField-8               	 1473628	       813.1 ns/op
+BenchmarkRegexMatch-8             	 1000000	      1141 ns/op
+BenchmarkRegexMatch-8             	 1000000	      1138 ns/op
+BenchmarkRegexMatch-8             	 1000000	      1138 ns/op
+BenchmarkRegexMatch-8             	 1000000	      1075 ns/op
+BenchmarkRegexMatch-8             	 1000000	      1078 ns/op
+BenchmarkBinaryOperators-8        	 2891120	       413.6 ns/op
+BenchmarkBinaryOperators-8        	 2894089	       413.2 ns/op
+BenchmarkBinaryOperators-8        	 2891299	       412.9 ns/op
+BenchmarkBinaryOperators-8        	 2886434	       414.4 ns/op
+BenchmarkBinaryOperators-8        	 2883632	       413.3 ns/op
+BenchmarkConcatSmall-8            	 1730454	       688.6 ns/op
+BenchmarkConcatSmall-8            	 1730092	       687.4 ns/op
+BenchmarkConcatSmall-8            	 1730014	       700.7 ns/op
+BenchmarkConcatSmall-8            	 1733638	       690.1 ns/op
+BenchmarkConcatSmall-8            	 1732149	       689.2 ns/op
+BenchmarkConcatLarge-8            	     141	   8182866 ns/op
+BenchmarkConcatLarge-8            	     141	   8428921 ns/op
+BenchmarkConcatLarge-8            	     139	   8218040 ns/op
+BenchmarkConcatLarge-8            	     139	   8420485 ns/op
+BenchmarkConcatLarge-8            	     138	   8492328 ns/op
+BenchmarkComparisons-8            	 3684354	       323.6 ns/op
+BenchmarkComparisons-8            	 3717096	       321.0 ns/op
+BenchmarkComparisons-8            	 3722360	       321.1 ns/op
+BenchmarkComparisons-8            	 3716374	       321.3 ns/op
+BenchmarkComparisons-8            	 3726710	       321.1 ns/op
+BenchmarkArrayOperations-8        	 2113299	       565.2 ns/op
+BenchmarkArrayOperations-8        	 2113783	       565.1 ns/op
+BenchmarkArrayOperations-8        	 2116396	       565.5 ns/op
+BenchmarkArrayOperations-8        	 2110519	       565.5 ns/op
+BenchmarkArrayOperations-8        	 2114427	       565.0 ns/op
+BenchmarkAssign-8                 	 4567243	       260.7 ns/op
+BenchmarkAssign-8                 	 4573126	       260.6 ns/op
+BenchmarkAssign-8                 	 4572972	       260.8 ns/op
+BenchmarkAssign-8                 	 4591869	       260.5 ns/op
+BenchmarkAssign-8                 	 4576586	       260.7 ns/op
+BenchmarkAugAssign-8              	 1218591	       981.8 ns/op
+BenchmarkAugAssign-8              	 1220430	       981.7 ns/op
+BenchmarkAugAssign-8              	 1210840	       981.3 ns/op
+BenchmarkAugAssign-8              	 1219254	       981.6 ns/op
+BenchmarkAugAssign-8              	 1214682	       981.7 ns/op
 PASS
-ok  	github.com/benhoyt/goawk/interp	271.350s
+ok  	github.com/benhoyt/goawk/interp	273.381s
diff --git a/goawk.go b/goawk.go
index e2a45c9..b9a2924 100644
--- a/goawk.go
+++ b/goawk.go
@@ -44,7 +44,7 @@ import (
 )
 
 const (
-	version    = "v1.14.0"
+	version    = "v1.15.0"
 	copyright  = "GoAWK " + version + " - Copyright (c) 2021 Ben Hoyt"
 	shortUsage = "usage: goawk [-F fs] [-v var=value] [-f progfile | 'prog'] [file ...]"
 	longUsage  = `Standard AWK arguments:
@@ -58,8 +58,9 @@ const (
 Additional GoAWK arguments:
   -cpuprofile file
         write CPU profile to file
-  -d    debug mode (print parsed AST to stderr)
-  -dt   show variable types debug info
+  -d    print parsed syntax tree to stderr (debug mode)
+  -da   print virtual machine assembly instructions to stderr
+  -dt   print variable type information to stderr
   -h    show this usage message
   -version
         show GoAWK version and exit
@@ -75,6 +76,7 @@ func main() {
 	fieldSep := " "
 	cpuprofile := ""
 	debug := false
+	debugAsm := false
 	debugTypes := false
 	memprofile := ""
 
@@ -117,6 +119,8 @@ func main() {
 			cpuprofile = os.Args[i]
 		case "-d":
 			debug = true
+		case "-da":
+			debugAsm = true
 		case "-dt":
 			debugTypes = true
 		case "-h", "--help":
@@ -206,9 +210,18 @@ func main() {
 		}
 		errorExitf("%s", err)
 	}
+
 	if debug {
 		fmt.Fprintln(os.Stderr, prog)
 	}
+
+	if debugAsm {
+		err := prog.Disassemble(os.Stderr)
+		if err != nil {
+			errorExitf("could not disassemble program: %v", err)
+		}
+	}
+
 	config := &interp.Config{
 		Argv0: filepath.Base(os.Args[0]),
 		Args:  expandWildcardsOnWindows(args),
diff --git a/internal/ast/ast.go b/internal/ast/ast.go
index 1007d44..a592b5a 100644
--- a/internal/ast/ast.go
+++ b/internal/ast/ast.go
@@ -10,6 +10,35 @@ import (
 	. "github.com/benhoyt/goawk/lexer"
 )
 
+// Program is an entire AWK program.
+type Program struct {
+	Begin     []Stmts
+	Actions   []Action
+	End       []Stmts
+	Functions []Function
+	Scalars   map[string]int
+	Arrays    map[string]int
+}
+
+// String returns an indented, pretty-printed version of the parsed
+// program.
+func (p *Program) String() string {
+	parts := []string{}
+	for _, ss := range p.Begin {
+		parts = append(parts, "BEGIN {\n"+ss.String()+"}")
+	}
+	for _, a := range p.Actions {
+		parts = append(parts, a.String())
+	}
+	for _, ss := range p.End {
+		parts = append(parts, "END {\n"+ss.String()+"}")
+	}
+	for _, function := range p.Functions {
+		parts = append(parts, function.String())
+	}
+	return strings.Join(parts, "\n\n")
+}
+
 // Stmts is a block containing multiple statements.
 type Stmts []Stmt
 
@@ -155,7 +184,11 @@ type NumExpr struct {
 }
 
 func (e *NumExpr) String() string {
-	return fmt.Sprintf("%.6g", e.Value)
+	if e.Value == float64(int(e.Value)) {
+		return strconv.Itoa(int(e.Value))
+	} else {
+		return fmt.Sprintf("%.6g", e.Value)
+	}
 }
 
 // StrExpr is a literal string like "foo".
diff --git a/internal/ast/specialvars.go b/internal/ast/specialvars.go
index 1ea06e4..c19552f 100644
--- a/internal/ast/specialvars.go
+++ b/internal/ast/specialvars.go
@@ -2,6 +2,10 @@
 
 package ast
 
+import (
+	"fmt"
+)
+
 const (
 	V_ILLEGAL = iota
 	V_ARGC
@@ -46,3 +50,43 @@ var specialVars = map[string]int{
 func SpecialVarIndex(name string) int {
 	return specialVars[name]
 }
+
+// SpecialVarName returns the name of the special variable by index.
+func SpecialVarName(index int) string {
+	switch index {
+	case V_ILLEGAL:
+		return "ILLEGAL"
+	case V_ARGC:
+		return "ARGC"
+	case V_CONVFMT:
+		return "CONVFMT"
+	case V_FILENAME:
+		return "FILENAME"
+	case V_FNR:
+		return "FNR"
+	case V_FS:
+		return "FS"
+	case V_NF:
+		return "NF"
+	case V_NR:
+		return "NR"
+	case V_OFMT:
+		return "OFMT"
+	case V_OFS:
+		return "OFS"
+	case V_ORS:
+		return "ORS"
+	case V_RLENGTH:
+		return "RLENGTH"
+	case V_RS:
+		return "RS"
+	case V_RSTART:
+		return "RSTART"
+	case V_RT:
+		return "RT"
+	case V_SUBSEP:
+		return "SUBSEP"
+	default:
+		return fmt.Sprintf("<unknown special var %d>", index)
+	}
+}
diff --git a/internal/compiler/compiler.go b/internal/compiler/compiler.go
new file mode 100644
index 0000000..15d02f1
--- /dev/null
+++ b/internal/compiler/compiler.go
@@ -0,0 +1,966 @@
+// Package compiler compiles an AST to virtual machine instructions.
+package compiler
+
+import (
+	"fmt"
+	"math"
+	"regexp"
+
+	"github.com/benhoyt/goawk/internal/ast"
+	"github.com/benhoyt/goawk/lexer"
+)
+
+// Program holds an entire compiled program.
+type Program struct {
+	Begin     []Opcode
+	Actions   []Action
+	End       []Opcode
+	Functions []Function
+	Nums      []float64
+	Strs      []string
+	Regexes   []*regexp.Regexp
+
+	// For disassembly
+	scalarNames     []string
+	arrayNames      []string
+	nativeFuncNames []string
+}
+
+// Action holds a compiled pattern-action block.
+type Action struct {
+	Pattern [][]Opcode
+	Body    []Opcode
+}
+
+// Function holds a compiled function.
+type Function struct {
+	Name       string
+	Params     []string
+	Arrays     []bool
+	NumScalars int
+	NumArrays  int
+	Body       []Opcode
+}
+
+// compileError is the internal error type raised in the rare cases when
+// compilation can't succeed, such as program too large (jump offsets greater
+// than 2GB). Most actual problems are caught as parse time.
+type compileError struct {
+	message string
+}
+
+func (e *compileError) Error() string {
+	return e.message
+}
+
+// Compile compiles an AST (parsed program) into virtual machine instructions.
+func Compile(prog *ast.Program) (compiledProg *Program, err error) {
+	defer func() {
+		// The compiler uses panic with a *compileError to signal compile
+		// errors internally, and they're caught here. This avoids the
+		// need to check errors everywhere.
+		if r := recover(); r != nil {
+			// Convert to compileError or re-panic
+			err = r.(*compileError)
+		}
+	}()
+
+	p := &Program{}
+
+	// Reuse identical constants across entire program.
+	indexes := constantIndexes{
+		nums:    make(map[float64]int),
+		strs:    make(map[string]int),
+		regexes: make(map[string]int),
+	}
+
+	// Compile functions. For functions called before they're defined or
+	// recursive functions, we have to set most p.Functions data first, then
+	// compile Body afterward.
+	p.Functions = make([]Function, len(prog.Functions))
+	for i, astFunc := range prog.Functions {
+		numArrays := 0
+		for _, a := range astFunc.Arrays {
+			if a {
+				numArrays++
+			}
+		}
+		compiledFunc := Function{
+			Name:       astFunc.Name,
+			Params:     astFunc.Params,
+			Arrays:     astFunc.Arrays,
+			NumScalars: len(astFunc.Arrays) - numArrays,
+			NumArrays:  numArrays,
+		}
+		p.Functions[i] = compiledFunc
+	}
+	for i, astFunc := range prog.Functions {
+		c := &compiler{program: p, indexes: indexes}
+		c.stmts(astFunc.Body)
+		p.Functions[i].Body = c.finish()
+	}
+
+	// Compile BEGIN blocks.
+	for _, stmts := range prog.Begin {
+		c := &compiler{program: p, indexes: indexes}
+		c.stmts(stmts)
+		p.Begin = append(p.Begin, c.finish()...)
+	}
+
+	// Compile pattern-action blocks.
+	for _, action := range prog.Actions {
+		var pattern [][]Opcode
+		switch len(action.Pattern) {
+		case 0:
+			// Always considered a match
+		case 1:
+			c := &compiler{program: p, indexes: indexes}
+			c.expr(action.Pattern[0])
+			pattern = [][]Opcode{c.finish()}
+		case 2:
+			c := &compiler{program: p, indexes: indexes}
+			c.expr(action.Pattern[0])
+			pattern = append(pattern, c.finish())
+			c = &compiler{program: p, indexes: indexes}
+			c.expr(action.Pattern[1])
+			pattern = append(pattern, c.finish())
+		}
+		var body []Opcode
+		if len(action.Stmts) > 0 {
+			c := &compiler{program: p, indexes: indexes}
+			c.stmts(action.Stmts)
+			body = c.finish()
+		}
+		p.Actions = append(p.Actions, Action{
+			Pattern: pattern,
+			Body:    body,
+		})
+	}
+
+	// Compile END blocks.
+	for _, stmts := range prog.End {
+		c := &compiler{program: p, indexes: indexes}
+		c.stmts(stmts)
+		p.End = append(p.End, c.finish()...)
+	}
+
+	// These are only used for disassembly, but set them up here.
+	p.scalarNames = make([]string, len(prog.Scalars))
+	for name, index := range prog.Scalars {
+		p.scalarNames[index] = name
+	}
+	p.arrayNames = make([]string, len(prog.Arrays))
+	for name, index := range prog.Arrays {
+		p.arrayNames[index] = name
+	}
+
+	return p, nil
+}
+
+// So we can look up the indexes of constants that have been used before.
+type constantIndexes struct {
+	nums    map[float64]int
+	strs    map[string]int
+	regexes map[string]int
+}
+
+// Holds the compilation state.
+type compiler struct {
+	program   *Program
+	indexes   constantIndexes
+	code      []Opcode
+	breaks    [][]int
+	continues [][]int
+}
+
+func (c *compiler) add(ops ...Opcode) {
+	c.code = append(c.code, ops...)
+}
+
+func (c *compiler) finish() []Opcode {
+	return c.code
+}
+
+func (c *compiler) stmts(stmts []ast.Stmt) {
+	for _, stmt := range stmts {
+		c.stmt(stmt)
+	}
+}
+
+func (c *compiler) stmt(stmt ast.Stmt) {
+	switch s := stmt.(type) {
+	case *ast.ExprStmt:
+		// Optimize assignment expressions to avoid the extra Dupe and Drop
+		switch expr := s.Expr.(type) {
+		case *ast.AssignExpr:
+			c.expr(expr.Right)
+			c.assign(expr.Left)
+			return
+
+		case *ast.IncrExpr:
+			// Pre or post doesn't matter for an assignment expression
+			switch target := expr.Expr.(type) {
+			case *ast.VarExpr:
+				switch target.Scope {
+				case ast.ScopeGlobal:
+					c.add(IncrGlobal, incrAmount(expr.Op), opcodeInt(target.Index))
+				case ast.ScopeLocal:
+					c.add(IncrLocal, incrAmount(expr.Op), opcodeInt(target.Index))
+				default: // ScopeSpecial
+					c.add(IncrSpecial, incrAmount(expr.Op), opcodeInt(target.Index))
+				}
+			case *ast.FieldExpr:
+				c.expr(target.Index)
+				c.add(IncrField, incrAmount(expr.Op))
+			case *ast.IndexExpr:
+				c.index(target.Index)
+				switch target.Array.Scope {
+				case ast.ScopeGlobal:
+					c.add(IncrArrayGlobal, incrAmount(expr.Op), opcodeInt(target.Array.Index))
+				default: // ScopeLocal
+					c.add(IncrArrayLocal, incrAmount(expr.Op), opcodeInt(target.Array.Index))
+				}
+			}
+			return
+
+		case *ast.AugAssignExpr:
+			c.expr(expr.Right)
+
+			var augOp AugOp
+			switch expr.Op {
+			case lexer.ADD:
+				augOp = AugOpAdd
+			case lexer.SUB:
+				augOp = AugOpSub
+			case lexer.MUL:
+				augOp = AugOpMul
+			case lexer.DIV:
+				augOp = AugOpDiv
+			case lexer.POW:
+				augOp = AugOpPow
+			default: // MOD
+				augOp = AugOpMod
+			}
+
+			switch target := expr.Left.(type) {
+			case *ast.VarExpr:
+				switch target.Scope {
+				case ast.ScopeGlobal:
+					c.add(AugAssignGlobal, Opcode(augOp), opcodeInt(target.Index))
+				case ast.ScopeLocal:
+					c.add(AugAssignLocal, Opcode(augOp), opcodeInt(target.Index))
+				default: // ScopeSpecial
+					c.add(AugAssignSpecial, Opcode(augOp), opcodeInt(target.Index))
+				}
+			case *ast.FieldExpr:
+				c.expr(target.Index)
+				c.add(AugAssignField, Opcode(augOp))
+			case *ast.IndexExpr:
+				c.index(target.Index)
+				switch target.Array.Scope {
+				case ast.ScopeGlobal:
+					c.add(AugAssignArrayGlobal, Opcode(augOp), opcodeInt(target.Array.Index))
+				default: // ScopeLocal
+					c.add(AugAssignArrayLocal, Opcode(augOp), opcodeInt(target.Array.Index))
+				}
+			}
+			return
+		}
+
+		// Non-optimized ExprStmt: push value and then drop it
+		c.expr(s.Expr)
+		c.add(Drop)
+
+	case *ast.PrintStmt:
+		if s.Redirect != lexer.ILLEGAL {
+			c.expr(s.Dest) // redirect destination
+		}
+		for _, a := range s.Args {
+			c.expr(a)
+		}
+		c.add(Print, opcodeInt(len(s.Args)), Opcode(s.Redirect))
+
+	case *ast.PrintfStmt:
+		if s.Redirect != lexer.ILLEGAL {
+			c.expr(s.Dest) // redirect destination
+		}
+		for _, a := range s.Args {
+			c.expr(a)
+		}
+		c.add(Printf, opcodeInt(len(s.Args)), Opcode(s.Redirect))
+
+	case *ast.IfStmt:
+		if len(s.Else) == 0 {
+			jumpOp := c.condition(s.Cond, true)
+			ifMark := c.jumpForward(jumpOp)
+			c.stmts(s.Body)
+			c.patchForward(ifMark)
+		} else {
+			jumpOp := c.condition(s.Cond, true)
+			ifMark := c.jumpForward(jumpOp)
+			c.stmts(s.Body)
+			elseMark := c.jumpForward(Jump)
+			c.patchForward(ifMark)
+			c.stmts(s.Else)
+			c.patchForward(elseMark)
+		}
+
+	case *ast.ForStmt:
+		if s.Pre != nil {
+			c.stmt(s.Pre)
+		}
+		c.breaks = append(c.breaks, []int{})
+		c.continues = append(c.continues, []int{})
+
+		// Optimization: include condition once before loop and at the end.
+		// This avoids one jump (a conditional jump at the top and an
+		// unconditional one at the end). This idea was stolen from an
+		// optimization CPython did recently in its "while" loop.
+		var mark int
+		if s.Cond != nil {
+			jumpOp := c.condition(s.Cond, true)
+			mark = c.jumpForward(jumpOp)
+		}
+
+		loopStart := c.labelBackward()
+		c.stmts(s.Body)
+		c.patchContinues()
+		if s.Post != nil {
+			c.stmt(s.Post)
+		}
+
+		if s.Cond != nil {
+			jumpOp := c.condition(s.Cond, false)
+			c.jumpBackward(loopStart, jumpOp)
+			c.patchForward(mark)
+		} else {
+			c.jumpBackward(loopStart, Jump)
+		}
+
+		c.patchBreaks()
+
+	case *ast.ForInStmt:
+		// ForIn is handled a bit differently from the other loops, because we
+		// want to use Go's "for range" construct directly in the interpreter.
+		// Otherwise we'd need to build a slice of all keys rather than
+		// iterating, or write our own hash table that has a more flexible
+		// iterator.
+		mark := c.jumpForward(ForIn, opcodeInt(int(s.Var.Scope)), opcodeInt(s.Var.Index),
+			Opcode(s.Array.Scope), opcodeInt(s.Array.Index))
+
+		c.breaks = append(c.breaks, nil) // nil tells BreakStmt it's a for-in loop
+		c.continues = append(c.continues, []int{})
+
+		c.stmts(s.Body)
+
+		c.patchForward(mark)
+		c.patchContinues()
+		c.breaks = c.breaks[:len(c.breaks)-1]
+
+	case *ast.ReturnStmt:
+		if s.Value != nil {
+			c.expr(s.Value)
+			c.add(Return)
+		} else {
+			c.add(ReturnNull)
+		}
+
+	case *ast.WhileStmt:
+		c.breaks = append(c.breaks, []int{})
+		c.continues = append(c.continues, []int{})
+
+		// Optimization: include condition once before loop and at the end.
+		// See ForStmt for more details.
+		jumpOp := c.condition(s.Cond, true)
+		mark := c.jumpForward(jumpOp)
+
+		loopStart := c.labelBackward()
+		c.stmts(s.Body)
+		c.patchContinues()
+
+		jumpOp = c.condition(s.Cond, false)
+		c.jumpBackward(loopStart, jumpOp)
+		c.patchForward(mark)
+
+		c.patchBreaks()
+
+	case *ast.DoWhileStmt:
+		c.breaks = append(c.breaks, []int{})
+		c.continues = append(c.continues, []int{})
+
+		loopStart := c.labelBackward()
+		c.stmts(s.Body)
+		c.patchContinues()
+
+		jumpOp := c.condition(s.Cond, false)
+		c.jumpBackward(loopStart, jumpOp)
+
+		c.patchBreaks()
+
+	case *ast.BreakStmt:
+		i := len(c.breaks) - 1
+		if c.breaks[i] == nil {
+			// Break in for-in loop is executed differently, use errBreak to exit
+			c.add(BreakForIn)
+		} else {
+			mark := c.jumpForward(Jump)
+			c.breaks[i] = append(c.breaks[i], mark)
+		}
+
+	case *ast.ContinueStmt:
+		i := len(c.continues) - 1
+		mark := c.jumpForward(Jump)
+		c.continues[i] = append(c.continues[i], mark)
+
+	case *ast.NextStmt:
+		c.add(Next)
+
+	case *ast.ExitStmt:
+		if s.Status != nil {
+			c.expr(s.Status)
+		} else {
+			c.expr(&ast.NumExpr{0})
+		}
+		c.add(Exit)
+
+	case *ast.DeleteStmt:
+		if len(s.Index) > 0 {
+			c.index(s.Index)
+			c.add(Delete, Opcode(s.Array.Scope), opcodeInt(s.Array.Index))
+		} else {
+			c.add(DeleteAll, Opcode(s.Array.Scope), opcodeInt(s.Array.Index))
+		}
+
+	case *ast.BlockStmt:
+		c.stmts(s.Body)
+
+	default:
+		// Should never happen
+		panic(fmt.Sprintf("unexpected stmt type: %T", stmt))
+	}
+}
+
+// Return the amount (+1 or -1) to add for an increment expression.
+func incrAmount(op lexer.Token) Opcode {
+	if op == lexer.INCR {
+		return 1
+	} else {
+		return -1 // DECR
+	}
+}
+
+// Generate opcodes for an assignment.
+func (c *compiler) assign(target ast.Expr) {
+	switch target := target.(type) {
+	case *ast.VarExpr:
+		switch target.Scope {
+		case ast.ScopeGlobal:
+			c.add(AssignGlobal, opcodeInt(target.Index))
+		case ast.ScopeLocal:
+			c.add(AssignLocal, opcodeInt(target.Index))
+		case ast.ScopeSpecial:
+			c.add(AssignSpecial, opcodeInt(target.Index))
+		}
+	case *ast.FieldExpr:
+		c.expr(target.Index)
+		c.add(AssignField)
+	case *ast.IndexExpr:
+		c.index(target.Index)
+		switch target.Array.Scope {
+		case ast.ScopeGlobal:
+			c.add(AssignArrayGlobal, opcodeInt(target.Array.Index))
+		case ast.ScopeLocal:
+			c.add(AssignArrayLocal, opcodeInt(target.Array.Index))
+		}
+	}
+}
+
+// Convert int to Opcode, raising a *compileError if it doesn't fit.
+func opcodeInt(n int) Opcode {
+	if n > math.MaxInt32 || n < math.MinInt32 {
+		// Two billion should be enough for anybody.
+		panic(&compileError{message: fmt.Sprintf("program too large (constant index or jump offset %d doesn't fit in int32)", n)})
+	}
+	return Opcode(n)
+}
+
+// Patch jump addresses for break statements in a loop.
+func (c *compiler) patchBreaks() {
+	breaks := c.breaks[len(c.breaks)-1]
+	for _, mark := range breaks {
+		c.patchForward(mark)
+	}
+	c.breaks = c.breaks[:len(c.breaks)-1]
+}
+
+// Patch jump addresses for continue statements in a loop
+func (c *compiler) patchContinues() {
+	continues := c.continues[len(c.continues)-1]
+	for _, mark := range continues {
+		c.patchForward(mark)
+	}
+	c.continues = c.continues[:len(c.continues)-1]
+}
+
+// Generate a forward jump (patched later) and return a "mark".
+func (c *compiler) jumpForward(jumpOp Opcode, args ...Opcode) int {
+	c.add(jumpOp)
+	c.add(args...)
+	c.add(0)
+	return len(c.code)
+}
+
+// Patch a previously-generated forward jump.
+func (c *compiler) patchForward(mark int) {
+	offset := len(c.code) - mark
+	c.code[mark-1] = opcodeInt(offset)
+}
+
+// Return a "label" for a subsequent backward jump.
+func (c *compiler) labelBackward() int {
+	return len(c.code)
+}
+
+// Jump to a previously-created label.
+func (c *compiler) jumpBackward(label int, jumpOp Opcode, args ...Opcode) {
+	offset := label - (len(c.code) + len(args) + 2)
+	c.add(jumpOp)
+	c.add(args...)
+	c.add(opcodeInt(offset))
+}
+
+func opIfElse(cond bool, trueOp, falseOp Opcode) Opcode {
+	if cond {
+		return trueOp
+	} else {
+		return falseOp
+	}
+}
+
+// Generate opcodes for a boolean condition.
+func (c *compiler) condition(expr ast.Expr, invert bool) Opcode {
+	jumpOp := func(normal, inverted Opcode) Opcode {
+		if invert {
+			return inverted
+		}
+		return normal
+	}
+
+	switch cond := expr.(type) {
+	case *ast.BinaryExpr:
+		// Optimize binary comparison expressions like "x < 10" into just
+		// JumpLess instead of two instructions (Less and JumpTrue).
+		switch cond.Op {
+		case lexer.EQUALS:
+			c.expr(cond.Left)
+			c.expr(cond.Right)
+			return jumpOp(JumpEquals, JumpNotEquals)
+
+		case lexer.NOT_EQUALS:
+			c.expr(cond.Left)
+			c.expr(cond.Right)
+			return jumpOp(JumpNotEquals, JumpEquals)
+
+		case lexer.LESS:
+			c.expr(cond.Left)
+			c.expr(cond.Right)
+			return jumpOp(JumpLess, JumpGreaterOrEqual)
+
+		case lexer.LTE:
+			c.expr(cond.Left)
+			c.expr(cond.Right)
+			return jumpOp(JumpLessOrEqual, JumpGreater)
+
+		case lexer.GREATER:
+			c.expr(cond.Left)
+			c.expr(cond.Right)
+			return jumpOp(JumpGreater, JumpLessOrEqual)
+
+		case lexer.GTE:
+			c.expr(cond.Left)
+			c.expr(cond.Right)
+			return jumpOp(JumpGreaterOrEqual, JumpLess)
+		}
+	}
+
+	// Fall back to evaluating the expression normally, followed by JumpTrue
+	// or JumpFalse.
+	c.expr(expr)
+	return jumpOp(JumpTrue, JumpFalse)
+}
+
+func (c *compiler) expr(expr ast.Expr) {
+	switch e := expr.(type) {
+	case *ast.NumExpr:
+		c.add(Num, opcodeInt(c.numIndex(e.Value)))
+
+	case *ast.StrExpr:
+		c.add(Str, opcodeInt(c.strIndex(e.Value)))
+
+	case *ast.FieldExpr:
+		switch index := e.Index.(type) {
+		case *ast.NumExpr:
+			if index.Value == float64(int32(index.Value)) {
+				// Optimize $i to FieldInt opcode with integer argument
+				c.add(FieldInt, opcodeInt(int(index.Value)))
+				return
+			}
+		}
+		c.expr(e.Index)
+		c.add(Field)
+
+	case *ast.VarExpr:
+		switch e.Scope {
+		case ast.ScopeGlobal:
+			c.add(Global, opcodeInt(e.Index))
+		case ast.ScopeLocal:
+			c.add(Local, opcodeInt(e.Index))
+		case ast.ScopeSpecial:
+			c.add(Special, opcodeInt(e.Index))
+		}
+
+	case *ast.RegExpr:
+		c.add(Regex, opcodeInt(c.regexIndex(e.Regex)))
+
+	case *ast.BinaryExpr:
+		// && and || are special cases as they're short-circuit operators.
+		switch e.Op {
+		case lexer.AND:
+			c.expr(e.Left)
+			c.add(Dupe)
+			mark := c.jumpForward(JumpFalse)
+			c.add(Drop)
+			c.expr(e.Right)
+			c.patchForward(mark)
+			c.add(Boolean)
+		case lexer.OR:
+			c.expr(e.Left)
+			c.add(Dupe)
+			mark := c.jumpForward(JumpTrue)
+			c.add(Drop)
+			c.expr(e.Right)
+			c.patchForward(mark)
+			c.add(Boolean)
+		default:
+			// All other binary expressions
+			c.expr(e.Left)
+			c.expr(e.Right)
+			c.binaryOp(e.Op)
+		}
+
+	case *ast.IncrExpr:
+		// Most IncrExpr (standalone) will be handled by the ExprStmt special case
+		op := Add
+		if e.Op == lexer.DECR {
+			op = Subtract
+		}
+		if e.Pre {
+			c.expr(e.Expr)
+			c.expr(&ast.NumExpr{1})
+			c.add(op)
+			c.add(Dupe)
+		} else {
+			c.expr(e.Expr)
+			c.expr(&ast.NumExpr{0})
+			c.add(Add)
+			c.add(Dupe)
+			c.expr(&ast.NumExpr{1})
+			c.add(op)
+		}
+		c.assign(e.Expr)
+
+	case *ast.AssignExpr:
+		// Most AssignExpr (standalone) will be handled by the ExprStmt special case
+		c.expr(e.Right)
+		c.add(Dupe)
+		c.assign(e.Left)
+
+	case *ast.AugAssignExpr:
+		// Most AugAssignExpr (standalone) will be handled by the ExprStmt special case
+		c.expr(e.Right)
+		c.expr(e.Left)
+		c.add(Swap)
+		c.binaryOp(e.Op)
+		c.add(Dupe)
+		c.assign(e.Left)
+
+	case *ast.CondExpr:
+		jump := c.condition(e.Cond, true)
+		ifMark := c.jumpForward(jump)
+		c.expr(e.True)
+		elseMark := c.jumpForward(Jump)
+		c.patchForward(ifMark)
+		c.expr(e.False)
+		c.patchForward(elseMark)
+
+	case *ast.IndexExpr:
+		c.index(e.Index)
+		switch e.Array.Scope {
+		case ast.ScopeGlobal:
+			c.add(ArrayGlobal, opcodeInt(e.Array.Index))
+		case ast.ScopeLocal:
+			c.add(ArrayLocal, opcodeInt(e.Array.Index))
+		}
+
+	case *ast.CallExpr:
+		// split and sub/gsub require special cases as they have lvalue arguments
+		switch e.Func {
+		case lexer.F_SPLIT:
+			c.expr(e.Args[0])
+			arrayExpr := e.Args[1].(*ast.ArrayExpr)
+			if len(e.Args) > 2 {
+				c.expr(e.Args[2])
+				c.add(CallSplitSep, Opcode(arrayExpr.Scope), opcodeInt(arrayExpr.Index))
+			} else {
+				c.add(CallSplit, Opcode(arrayExpr.Scope), opcodeInt(arrayExpr.Index))
+			}
+			return
+		case lexer.F_SUB, lexer.F_GSUB:
+			op := BuiltinSub
+			if e.Func == lexer.F_GSUB {
+				op = BuiltinGsub
+			}
+			var target ast.Expr = &ast.FieldExpr{&ast.NumExpr{0}} // default value and target is $0
+			if len(e.Args) == 3 {
+				target = e.Args[2]
+			}
+			c.expr(e.Args[0])
+			c.expr(e.Args[1])
+			c.expr(target)
+			c.add(CallBuiltin, Opcode(op))
+			c.assign(target)
+			return
+		}
+
+		for _, arg := range e.Args {
+			c.expr(arg)
+		}
+		switch e.Func {
+		case lexer.F_ATAN2:
+			c.add(CallBuiltin, Opcode(BuiltinAtan2))
+		case lexer.F_CLOSE:
+			c.add(CallBuiltin, Opcode(BuiltinClose))
+		case lexer.F_COS:
+			c.add(CallBuiltin, Opcode(BuiltinCos))
+		case lexer.F_EXP:
+			c.add(CallBuiltin, Opcode(BuiltinExp))
+		case lexer.F_FFLUSH:
+			if len(e.Args) > 0 {
+				c.add(CallBuiltin, Opcode(BuiltinFflush))
+			} else {
+				c.add(CallBuiltin, Opcode(BuiltinFflushAll))
+			}
+		case lexer.F_INDEX:
+			c.add(CallBuiltin, Opcode(BuiltinIndex))
+		case lexer.F_INT:
+			c.add(CallBuiltin, Opcode(BuiltinInt))
+		case lexer.F_LENGTH:
+			if len(e.Args) > 0 {
+				c.add(CallBuiltin, Opcode(BuiltinLengthArg))
+			} else {
+				c.add(CallBuiltin, Opcode(BuiltinLength))
+			}
+		case lexer.F_LOG:
+			c.add(CallBuiltin, Opcode(BuiltinLog))
+		case lexer.F_MATCH:
+			c.add(CallBuiltin, Opcode(BuiltinMatch))
+		case lexer.F_RAND:
+			c.add(CallBuiltin, Opcode(BuiltinRand))
+		case lexer.F_SIN:
+			c.add(CallBuiltin, Opcode(BuiltinSin))
+		case lexer.F_SPRINTF:
+			c.add(CallSprintf, opcodeInt(len(e.Args)))
+		case lexer.F_SQRT:
+			c.add(CallBuiltin, Opcode(BuiltinSqrt))
+		case lexer.F_SRAND:
+			if len(e.Args) > 0 {
+				c.add(CallBuiltin, Opcode(BuiltinSrandSeed))
+			} else {
+				c.add(CallBuiltin, Opcode(BuiltinSrand))
+			}
+		case lexer.F_SUBSTR:
+			if len(e.Args) > 2 {
+				c.add(CallBuiltin, Opcode(BuiltinSubstrLength))
+			} else {
+				c.add(CallBuiltin, Opcode(BuiltinSubstr))
+			}
+		case lexer.F_SYSTEM:
+			c.add(CallBuiltin, Opcode(BuiltinSystem))
+		case lexer.F_TOLOWER:
+			c.add(CallBuiltin, Opcode(BuiltinTolower))
+		case lexer.F_TOUPPER:
+			c.add(CallBuiltin, Opcode(BuiltinToupper))
+		default:
+			panic(fmt.Sprintf("unexpected function: %s", e.Func))
+		}
+
+	case *ast.UnaryExpr:
+		c.expr(e.Value)
+		switch e.Op {
+		case lexer.SUB:
+			c.add(UnaryMinus)
+		case lexer.NOT:
+			c.add(Not)
+		default: // ADD
+			c.add(UnaryPlus)
+		}
+
+	case *ast.InExpr:
+		c.index(e.Index)
+		switch e.Array.Scope {
+		case ast.ScopeGlobal:
+			c.add(InGlobal, opcodeInt(e.Array.Index))
+		default: // ScopeLocal
+			c.add(InLocal, opcodeInt(e.Array.Index))
+		}
+
+	case *ast.UserCallExpr:
+		if e.Native {
+			for _, arg := range e.Args {
+				c.expr(arg)
+			}
+			c.add(CallNative, opcodeInt(e.Index), opcodeInt(len(e.Args)))
+			for len(c.program.nativeFuncNames) <= e.Index {
+				c.program.nativeFuncNames = append(c.program.nativeFuncNames, "")
+			}
+			c.program.nativeFuncNames[e.Index] = e.Name
+		} else {
+			f := c.program.Functions[e.Index]
+			var arrayOpcodes []Opcode
+			numScalarArgs := 0
+			for i, arg := range e.Args {
+				if f.Arrays[i] {
+					a := arg.(*ast.VarExpr)
+					arrayOpcodes = append(arrayOpcodes, Opcode(a.Scope), opcodeInt(a.Index))
+				} else {
+					c.expr(arg)
+					numScalarArgs++
+				}
+			}
+			if numScalarArgs < f.NumScalars {
+				c.add(Nulls, opcodeInt(f.NumScalars-numScalarArgs))
+			}
+			c.add(CallUser, opcodeInt(e.Index), opcodeInt(len(arrayOpcodes)/2))
+			c.add(arrayOpcodes...)
+		}
+
+	case *ast.GetlineExpr:
+		redirect := func() Opcode {
+			switch {
+			case e.Command != nil:
+				c.expr(e.Command)
+				return Opcode(lexer.PIPE)
+			case e.File != nil:
+				c.expr(e.File)
+				return Opcode(lexer.LESS)
+			default:
+				return Opcode(lexer.ILLEGAL)
+			}
+		}
+		switch target := e.Target.(type) {
+		case *ast.VarExpr:
+			switch target.Scope {
+			case ast.ScopeGlobal:
+				c.add(GetlineGlobal, redirect(), opcodeInt(target.Index))
+			case ast.ScopeLocal:
+				c.add(GetlineLocal, redirect(), opcodeInt(target.Index))
+			case ast.ScopeSpecial:
+				c.add(GetlineSpecial, redirect(), opcodeInt(target.Index))
+			}
+		case *ast.FieldExpr:
+			c.expr(target.Index)
+			c.add(GetlineField, redirect())
+		case *ast.IndexExpr:
+			c.index(target.Index)
+			c.add(GetlineArray, redirect(), Opcode(target.Array.Scope), opcodeInt(target.Array.Index))
+		default:
+			c.add(Getline, redirect())
+		}
+
+	default:
+		// Should never happen
+		panic(fmt.Sprintf("unexpected expr type: %T", expr))
+	}
+}
+
+// Add (or reuse) a number constant and returns its index.
+func (c *compiler) numIndex(n float64) int {
+	if index, ok := c.indexes.nums[n]; ok {
+		return index // reuse existing constant
+	}
+	index := len(c.program.Nums)
+	c.program.Nums = append(c.program.Nums, n)
+	c.indexes.nums[n] = index
+	return index
+}
+
+// Add (or reuse) a string constant and returns its index.
+func (c *compiler) strIndex(s string) int {
+	if index, ok := c.indexes.strs[s]; ok {
+		return index // reuse existing constant
+	}
+	index := len(c.program.Strs)
+	c.program.Strs = append(c.program.Strs, s)
+	c.indexes.strs[s] = index
+	return index
+}
+
+// Add (or reuse) a regex constant and returns its index.
+func (c *compiler) regexIndex(r string) int {
+	if index, ok := c.indexes.regexes[r]; ok {
+		return index // reuse existing constant
+	}
+	index := len(c.program.Regexes)
+	c.program.Regexes = append(c.program.Regexes, regexp.MustCompile(r))
+	c.indexes.regexes[r] = index
+	return index
+}
+
+func (c *compiler) binaryOp(op lexer.Token) {
+	var opcode Opcode
+	switch op {
+	case lexer.ADD:
+		opcode = Add
+	case lexer.SUB:
+		opcode = Subtract
+	case lexer.EQUALS:
+		opcode = Equals
+	case lexer.LESS:
+		opcode = Less
+	case lexer.LTE:
+		opcode = LessOrEqual
+	case lexer.CONCAT:
+		opcode = Concat
+	case lexer.MUL:
+		opcode = Multiply
+	case lexer.DIV:
+		opcode = Divide
+	case lexer.GREATER:
+		opcode = Greater
+	case lexer.GTE:
+		opcode = GreaterOrEqual
+	case lexer.NOT_EQUALS:
+		opcode = NotEquals
+	case lexer.MATCH:
+		opcode = Match
+	case lexer.NOT_MATCH:
+		opcode = NotMatch
+	case lexer.POW:
+		opcode = Power
+	case lexer.MOD:
+		opcode = Modulo
+	default:
+		panic(fmt.Sprintf("unexpected binary operation: %s", op))
+	}
+	c.add(opcode)
+}
+
+// Generate an array index, handling multi-indexes properly.
+func (c *compiler) index(index []ast.Expr) {
+	for _, expr := range index {
+		c.expr(expr)
+	}
+	if len(index) > 1 {
+		c.add(MultiIndex, opcodeInt(len(index)))
+	}
+}
diff --git a/internal/compiler/disassembler.go b/internal/compiler/disassembler.go
new file mode 100644
index 0000000..807be3a
--- /dev/null
+++ b/internal/compiler/disassembler.go
@@ -0,0 +1,487 @@
+// Disassembles compiled program to text assembly instructions
+
+package compiler
+
+import (
+	"fmt"
+	"io"
+	"strings"
+
+	"github.com/benhoyt/goawk/internal/ast"
+	"github.com/benhoyt/goawk/lexer"
+)
+
+// Disassemble writes a human-readable form of the program's virtual machine
+// instructions to writer.
+func (p *Program) Disassemble(writer io.Writer) error {
+	if p.Begin != nil {
+		d := &disassembler{
+			program:         p,
+			writer:          writer,
+			code:            p.Begin,
+			nativeFuncNames: p.nativeFuncNames,
+		}
+		err := d.disassemble("BEGIN")
+		if err != nil {
+			return err
+		}
+	}
+
+	for _, action := range p.Actions {
+		switch len(action.Pattern) {
+		case 0:
+			// Nothing to do here.
+		case 1:
+			d := &disassembler{
+				program:         p,
+				writer:          writer,
+				code:            action.Pattern[0],
+				nativeFuncNames: p.nativeFuncNames,
+			}
+			err := d.disassemble("pattern")
+			if err != nil {
+				return err
+			}
+		case 2:
+			d := &disassembler{
+				program:         p,
+				writer:          writer,
+				code:            action.Pattern[0],
+				nativeFuncNames: p.nativeFuncNames,
+			}
+			err := d.disassemble("start")
+			if err != nil {
+				return err
+			}
+			d = &disassembler{
+				program:         p,
+				writer:          writer,
+				code:            action.Pattern[1],
+				nativeFuncNames: p.nativeFuncNames,
+			}
+			err = d.disassemble("stop")
+			if err != nil {
+				return err
+			}
+		}
+		if len(action.Body) > 0 {
+			d := &disassembler{
+				program:         p,
+				writer:          writer,
+				code:            action.Body,
+				nativeFuncNames: p.nativeFuncNames,
+			}
+			err := d.disassemble("{ body }")
+			if err != nil {
+				return err
+			}
+		}
+	}
+
+	if p.End != nil {
+		d := &disassembler{
+			program:         p,
+			writer:          writer,
+			code:            p.End,
+			nativeFuncNames: p.nativeFuncNames,
+		}
+		err := d.disassemble("END")
+		if err != nil {
+			return err
+		}
+	}
+
+	for i, f := range p.Functions {
+		d := &disassembler{
+			program:         p,
+			writer:          writer,
+			code:            f.Body,
+			nativeFuncNames: p.nativeFuncNames,
+			funcIndex:       i,
+		}
+		err := d.disassemble("function " + f.Name)
+		if err != nil {
+			return err
+		}
+	}
+
+	return nil
+}
+
+// Disassembles a single block of opcodes.
+type disassembler struct {
+	program         *Program
+	writer          io.Writer
+	code            []Opcode
+	nativeFuncNames []string
+	funcIndex       int
+	ip              int
+	opAddr          int
+	err             error
+}
+
+func (d *disassembler) disassemble(prefix string) error {
+	if prefix != "" {
+		d.writef("        // %s\n", prefix)
+	}
+
+	for d.ip < len(d.code) && d.err == nil {
+		d.opAddr = d.ip
+		op := d.fetch()
+
+		switch op {
+		case Num:
+			index := d.fetch()
+			num := d.program.Nums[index]
+			if num == float64(int(num)) {
+				d.writeOpf("Num %d (%d)", int(num), index)
+			} else {
+				d.writeOpf("Num %.6g (%d)", num, index)
+			}
+
+		case Str:
+			index := d.fetch()
+			d.writeOpf("Str %q (%d)", d.program.Strs[index], index)
+
+		case FieldInt:
+			index := d.fetch()
+			d.writeOpf("FieldInt %d", index)
+
+		case Global:
+			index := d.fetch()
+			d.writeOpf("Global %s", d.program.scalarNames[index])
+
+		case Local:
+			index := int(d.fetch())
+			d.writeOpf("Local %s", d.localName(index))
+
+		case Special:
+			index := d.fetch()
+			d.writeOpf("Special %s", ast.SpecialVarName(int(index)))
+
+		case ArrayGlobal:
+			arrayIndex := d.fetch()
+			d.writeOpf("ArrayGlobal %s", d.program.arrayNames[arrayIndex])
+
+		case ArrayLocal:
+			arrayIndex := d.fetch()
+			d.writeOpf("ArrayLocal %s", d.localArrayName(int(arrayIndex)))
+
+		case InGlobal:
+			arrayIndex := d.fetch()
+			d.writeOpf("InGlobal %s", d.program.arrayNames[arrayIndex])
+
+		case InLocal:
+			arrayIndex := int(d.fetch())
+			d.writeOpf("InLocal %s", d.localArrayName(arrayIndex))
+
+		case AssignGlobal:
+			index := d.fetch()
+			d.writeOpf("AssignGlobal %s", d.program.scalarNames[index])
+
+		case AssignLocal:
+			index := int(d.fetch())
+			d.writeOpf("AssignLocal %s", d.localName(index))
+
+		case AssignSpecial:
+			index := d.fetch()
+			d.writeOpf("AssignSpecial %s", ast.SpecialVarName(int(index)))
+
+		case AssignArrayGlobal:
+			arrayIndex := d.fetch()
+			d.writeOpf("AssignArrayGlobal %s", d.program.arrayNames[arrayIndex])
+
+		case AssignArrayLocal:
+			arrayIndex := int(d.fetch())
+			d.writeOpf("AssignArrayLocal %s", d.localArrayName(arrayIndex))
+
+		case Delete:
+			arrayScope := ast.VarScope(d.fetch())
+			arrayIndex := int(d.fetch())
+			d.writeOpf("Delete %s", d.arrayName(arrayScope, arrayIndex))
+
+		case DeleteAll:
+			arrayScope := ast.VarScope(d.fetch())
+			arrayIndex := int(d.fetch())
+			d.writeOpf("DeleteAll %s", d.arrayName(arrayScope, arrayIndex))
+
+		case IncrField:
+			amount := d.fetch()
+			d.writeOpf("IncrField %d", amount)
+
+		case IncrGlobal:
+			amount := d.fetch()
+			index := d.fetch()
+			d.writeOpf("IncrGlobal %d %s", amount, d.program.scalarNames[index])
+
+		case IncrLocal:
+			amount := d.fetch()
+			index := int(d.fetch())
+			d.writeOpf("IncrLocal %d %s", amount, d.localName(index))
+
+		case IncrSpecial:
+			amount := d.fetch()
+			index := d.fetch()
+			d.writeOpf("IncrSpecial %d %s", amount, ast.SpecialVarName(int(index)))
+
+		case IncrArrayGlobal:
+			amount := d.fetch()
+			arrayIndex := d.fetch()
+			d.writeOpf("IncrArrayGlobal %d %s", amount, d.program.arrayNames[arrayIndex])
+
+		case IncrArrayLocal:
+			amount := d.fetch()
+			arrayIndex := int(d.fetch())
+			d.writeOpf("IncrArrayLocal %d %s", amount, d.localArrayName(arrayIndex))
+
+		case AugAssignField:
+			operation := AugOp(d.fetch())
+			d.writeOpf("AugAssignField %s", operation)
+
+		case AugAssignGlobal:
+			operation := AugOp(d.fetch())
+			index := d.fetch()
+			d.writeOpf("AugAssignGlobal %s %s", operation, d.program.scalarNames[index])
+
+		case AugAssignLocal:
+			operation := AugOp(d.fetch())
+			index := int(d.fetch())
+			d.writeOpf("AugAssignLocal %s %s", operation, d.localName(index))
+
+		case AugAssignSpecial:
+			operation := AugOp(d.fetch())
+			index := d.fetch()
+			d.writeOpf("AugAssignSpecial %s %d", operation, ast.SpecialVarName(int(index)))
+
+		case AugAssignArrayGlobal:
+			operation := AugOp(d.fetch())
+			arrayIndex := d.fetch()
+			d.writeOpf("AugAssignArrayGlobal %s %s", operation, d.program.arrayNames[arrayIndex])
+
+		case AugAssignArrayLocal:
+			operation := AugOp(d.fetch())
+			arrayIndex := int(d.fetch())
+			d.writeOpf("AugAssignArrayLocal %s %s", operation, d.localArrayName(arrayIndex))
+
+		case Regex:
+			regexIndex := d.fetch()
+			d.writeOpf("Regex %q (%d)", d.program.Regexes[regexIndex], regexIndex)
+
+		case MultiIndex:
+			num := d.fetch()
+			d.writeOpf("MultiIndex %d", num)
+
+		case Jump:
+			offset := d.fetch()
+			d.writeOpf("Jump 0x%04x", d.ip+int(offset))
+
+		case JumpFalse:
+			offset := d.fetch()
+			d.writeOpf("JumpFalse 0x%04x", d.ip+int(offset))
+
+		case JumpTrue:
+			offset := d.fetch()
+			d.writeOpf("JumpTrue 0x%04x", d.ip+int(offset))
+
+		case JumpEquals:
+			offset := d.fetch()
+			d.writeOpf("JumpEquals 0x%04x", d.ip+int(offset))
+
+		case JumpNotEquals:
+			offset := d.fetch()
+			d.writeOpf("JumpNotEquals 0x%04x", d.ip+int(offset))
+
+		case JumpLess:
+			offset := d.fetch()
+			d.writeOpf("JumpLess 0x%04x", d.ip+int(offset))
+
+		case JumpGreater:
+			offset := d.fetch()
+			d.writeOpf("JumpGreater 0x%04x", d.ip+int(offset))
+
+		case JumpLessOrEqual:
+			offset := d.fetch()
+			d.writeOpf("JumpLessOrEqual 0x%04x", d.ip+int(offset))
+
+		case JumpGreaterOrEqual:
+			offset := d.fetch()
+			d.writeOpf("JumpGreaterOrEqual 0x%04x", d.ip+int(offset))
+
+		case ForIn:
+			varScope := ast.VarScope(d.fetch())
+			varIndex := int(d.fetch())
+			arrayScope := ast.VarScope(d.fetch())
+			arrayIndex := int(d.fetch())
+			offset := d.fetch()
+			d.writeOpf("ForIn %s %s 0x%04x", d.varName(varScope, varIndex), d.arrayName(arrayScope, arrayIndex), d.ip+int(offset))
+
+		case CallBuiltin:
+			builtinOp := BuiltinOp(d.fetch())
+			d.writeOpf("CallBuiltin %s", builtinOp)
+
+		case CallSplit:
+			arrayScope := ast.VarScope(d.fetch())
+			arrayIndex := int(d.fetch())
+			d.writeOpf("CallSplit %s", d.arrayName(arrayScope, arrayIndex))
+
+		case CallSplitSep:
+			arrayScope := ast.VarScope(d.fetch())
+			arrayIndex := int(d.fetch())
+			d.writeOpf("CallSplitSep %s", d.arrayName(arrayScope, arrayIndex))
+
+		case CallSprintf:
+			numArgs := d.fetch()
+			d.writeOpf("CallSprintf %d", numArgs)
+
+		case CallUser:
+			funcIndex := d.fetch()
+			numArrayArgs := int(d.fetch())
+			var arrayArgs []string
+			for i := 0; i < numArrayArgs; i++ {
+				arrayScope := ast.VarScope(d.fetch())
+				arrayIndex := int(d.fetch())
+				arrayArgs = append(arrayArgs, d.arrayName(arrayScope, arrayIndex))
+			}
+			d.writeOpf("CallUser %s [%s]", d.program.Functions[funcIndex].Name, strings.Join(arrayArgs, ", "))
+
+		case CallNative:
+			funcIndex := d.fetch()
+			numArgs := d.fetch()
+			d.writeOpf("CallNative %s %d", d.nativeFuncNames[funcIndex], numArgs)
+
+		case Nulls:
+			numNulls := d.fetch()
+			d.writeOpf("Nulls %d", numNulls)
+
+		case Print:
+			numArgs := d.fetch()
+			redirect := lexer.Token(d.fetch())
+			if redirect == lexer.ILLEGAL {
+				d.writeOpf("Print %d", numArgs)
+			} else {
+				d.writeOpf("Print %d %s", numArgs, redirect)
+			}
+
+		case Printf:
+			numArgs := d.fetch()
+			redirect := lexer.Token(d.fetch())
+			if redirect == lexer.ILLEGAL {
+				d.writeOpf("Printf %d", numArgs)
+			} else {
+				d.writeOpf("Printf %d %s", numArgs, redirect)
+			}
+
+		case Getline:
+			redirect := lexer.Token(d.fetch())
+			d.writeOpf("Getline %s", redirect)
+
+		case GetlineField:
+			redirect := lexer.Token(d.fetch())
+			d.writeOpf("GetlineField %s", redirect)
+
+		case GetlineGlobal:
+			redirect := lexer.Token(d.fetch())
+			index := d.fetch()
+			d.writeOpf("GetlineGlobal %s %s", redirect, d.program.scalarNames[index])
+
+		case GetlineLocal:
+			redirect := lexer.Token(d.fetch())
+			index := int(d.fetch())
+			d.writeOpf("GetlineLocal %s %s", redirect, d.localName(index))
+
+		case GetlineSpecial:
+			redirect := lexer.Token(d.fetch())
+			index := d.fetch()
+			d.writeOpf("GetlineSpecial %s %s", redirect, ast.SpecialVarName(int(index)))
+
+		case GetlineArray:
+			redirect := lexer.Token(d.fetch())
+			arrayScope := ast.VarScope(d.fetch())
+			arrayIndex := int(d.fetch())
+			d.writeOpf("GetlineArray %s %s", redirect, d.arrayName(arrayScope, arrayIndex))
+
+		default:
+			// Handles all other opcodes with no arguments
+			d.writeOpf("%s", op)
+		}
+	}
+
+	d.writef("\n")
+	return d.err
+}
+
+// Fetch the next opcode and increment the "instruction pointer".
+func (d *disassembler) fetch() Opcode {
+	op := d.code[d.ip]
+	d.ip++
+	return op
+}
+
+// Write formatted string to the disassembly output.
+func (d *disassembler) writef(format string, args ...interface{}) {
+	if d.err != nil {
+		return
+	}
+	_, d.err = fmt.Fprintf(d.writer, format, args...)
+}
+
+// Write formatted opcode (with address and newline) to disassembly output.
+func (d *disassembler) writeOpf(format string, args ...interface{}) {
+	if d.err != nil {
+		return
+	}
+	addrStr := fmt.Sprintf("%04x", d.opAddr)
+	_, d.err = fmt.Fprintf(d.writer, addrStr+"    "+format+"\n", args...)
+}
+
+// Return the scalar variable name described by scope and index.
+func (d *disassembler) varName(scope ast.VarScope, index int) string {
+	switch scope {
+	case ast.ScopeGlobal:
+		return d.program.scalarNames[index]
+	case ast.ScopeLocal:
+		return d.localName(index)
+	default: // ScopeSpecial
+		return ast.SpecialVarName(index)
+	}
+}
+
+// Return the local variable name with the given index.
+func (d *disassembler) localName(index int) string {
+	f := d.program.Functions[d.funcIndex]
+	n := 0
+	for i, p := range f.Params {
+		if f.Arrays[i] {
+			continue
+		}
+		if n == index {
+			return p
+		}
+		n++
+	}
+	panic(fmt.Sprintf("unexpected local variable index %d", index))
+}
+
+// Return the array variable name describes by scope and index.
+func (d *disassembler) arrayName(scope ast.VarScope, index int) string {
+	if scope == ast.ScopeLocal {
+		return d.localArrayName(index)
+	}
+	return d.program.arrayNames[index]
+}
+
+// Return the local array name with the given index.
+func (d *disassembler) localArrayName(index int) string {
+	f := d.program.Functions[d.funcIndex]
+	n := 0
+	for i, p := range f.Params {
+		if !f.Arrays[i] {
+			continue
+		}
+		if n == index {
+			return p
+		}
+		n++
+	}
+	panic(fmt.Sprintf("unexpected local array index %d", index))
+}
diff --git a/internal/compiler/opcode_string.go b/internal/compiler/opcode_string.go
new file mode 100644
index 0000000..ebb2f30
--- /dev/null
+++ b/internal/compiler/opcode_string.go
@@ -0,0 +1,171 @@
+// Code generated by "stringer -type=Opcode,AugOp,BuiltinOp"; DO NOT EDIT.
+
+package compiler
+
+import "strconv"
+
+func _() {
+	// An "invalid array index" compiler error signifies that the constant values have changed.
+	// Re-run the stringer command to generate them again.
+	var x [1]struct{}
+	_ = x[Nop-0]
+	_ = x[Num-1]
+	_ = x[Str-2]
+	_ = x[Dupe-3]
+	_ = x[Drop-4]
+	_ = x[Swap-5]
+	_ = x[Field-6]
+	_ = x[FieldInt-7]
+	_ = x[Global-8]
+	_ = x[Local-9]
+	_ = x[Special-10]
+	_ = x[ArrayGlobal-11]
+	_ = x[ArrayLocal-12]
+	_ = x[InGlobal-13]
+	_ = x[InLocal-14]
+	_ = x[AssignField-15]
+	_ = x[AssignGlobal-16]
+	_ = x[AssignLocal-17]
+	_ = x[AssignSpecial-18]
+	_ = x[AssignArrayGlobal-19]
+	_ = x[AssignArrayLocal-20]
+	_ = x[Delete-21]
+	_ = x[DeleteAll-22]
+	_ = x[IncrField-23]
+	_ = x[IncrGlobal-24]
+	_ = x[IncrLocal-25]
+	_ = x[IncrSpecial-26]
+	_ = x[IncrArrayGlobal-27]
+	_ = x[IncrArrayLocal-28]
+	_ = x[AugAssignField-29]
+	_ = x[AugAssignGlobal-30]
+	_ = x[AugAssignLocal-31]
+	_ = x[AugAssignSpecial-32]
+	_ = x[AugAssignArrayGlobal-33]
+	_ = x[AugAssignArrayLocal-34]
+	_ = x[Regex-35]
+	_ = x[MultiIndex-36]
+	_ = x[Add-37]
+	_ = x[Subtract-38]
+	_ = x[Multiply-39]
+	_ = x[Divide-40]
+	_ = x[Power-41]
+	_ = x[Modulo-42]
+	_ = x[Equals-43]
+	_ = x[NotEquals-44]
+	_ = x[Less-45]
+	_ = x[Greater-46]
+	_ = x[LessOrEqual-47]
+	_ = x[GreaterOrEqual-48]
+	_ = x[Concat-49]
+	_ = x[Match-50]
+	_ = x[NotMatch-51]
+	_ = x[Not-52]
+	_ = x[UnaryMinus-53]
+	_ = x[UnaryPlus-54]
+	_ = x[Boolean-55]
+	_ = x[Jump-56]
+	_ = x[JumpFalse-57]
+	_ = x[JumpTrue-58]
+	_ = x[JumpEquals-59]
+	_ = x[JumpNotEquals-60]
+	_ = x[JumpLess-61]
+	_ = x[JumpGreater-62]
+	_ = x[JumpLessOrEqual-63]
+	_ = x[JumpGreaterOrEqual-64]
+	_ = x[Next-65]
+	_ = x[Exit-66]
+	_ = x[ForIn-67]
+	_ = x[BreakForIn-68]
+	_ = x[CallBuiltin-69]
+	_ = x[CallSplit-70]
+	_ = x[CallSplitSep-71]
+	_ = x[CallSprintf-72]
+	_ = x[CallUser-73]
+	_ = x[CallNative-74]
+	_ = x[Return-75]
+	_ = x[ReturnNull-76]
+	_ = x[Nulls-77]
+	_ = x[Print-78]
+	_ = x[Printf-79]
+	_ = x[Getline-80]
+	_ = x[GetlineField-81]
+	_ = x[GetlineGlobal-82]
+	_ = x[GetlineLocal-83]
+	_ = x[GetlineSpecial-84]
+	_ = x[GetlineArray-85]
+	_ = x[EndOpcode-86]
+}
+
+const _Opcode_name = "NopNumStrDupeDropSwapFieldFieldIntGlobalLocalSpecialArrayGlobalArrayLocalInGlobalInLocalAssignFieldAssignGlobalAssignLocalAssignSpecialAssignArrayGlobalAssignArrayLocalDeleteDeleteAllIncrFieldIncrGlobalIncrLocalIncrSpecialIncrArrayGlobalIncrArrayLocalAugAssignFieldAugAssignGlobalAugAssignLocalAugAssignSpecialAugAssignArrayGlobalAugAssignArrayLocalRegexMultiIndexAddSubtractMultiplyDividePowerModuloEqualsNotEqualsLessGreaterLessOrEqualGreaterOrEqualConcatMatchNotMatchNotUnaryMinusUnaryPlusBooleanJumpJumpFalseJumpTrueJumpEqualsJumpNotEqualsJumpLessJumpGreaterJumpLessOrEqualJumpGreaterOrEqualNextExitForInBreakForInCallBuiltinCallSplitCallSplitSepCallSprintfCallUserCallNativeReturnReturnNullNullsPrintPrintfGetlineGetlineFieldGetlineGlobalGetlineLocalGetlineSpecialGetlineArrayEndOpcode"
+
+var _Opcode_index = [...]uint16{0, 3, 6, 9, 13, 17, 21, 26, 34, 40, 45, 52, 63, 73, 81, 88, 99, 111, 122, 135, 152, 168, 174, 183, 192, 202, 211, 222, 237, 251, 265, 280, 294, 310, 330, 349, 354, 364, 367, 375, 383, 389, 394, 400, 406, 415, 419, 426, 437, 451, 457, 462, 470, 473, 483, 492, 499, 503, 512, 520, 530, 543, 551, 562, 577, 595, 599, 603, 608, 618, 629, 638, 650, 661, 669, 679, 685, 695, 700, 705, 711, 718, 730, 743, 755, 769, 781, 790}
+
+func (i Opcode) String() string {
+	if i < 0 || i >= Opcode(len(_Opcode_index)-1) {
+		return "Opcode(" + strconv.FormatInt(int64(i), 10) + ")"
+	}
+	return _Opcode_name[_Opcode_index[i]:_Opcode_index[i+1]]
+}
+func _() {
+	// An "invalid array index" compiler error signifies that the constant values have changed.
+	// Re-run the stringer command to generate them again.
+	var x [1]struct{}
+	_ = x[AugOpAdd-0]
+	_ = x[AugOpSub-1]
+	_ = x[AugOpMul-2]
+	_ = x[AugOpDiv-3]
+	_ = x[AugOpPow-4]
+	_ = x[AugOpMod-5]
+}
+
+const _AugOp_name = "AugOpAddAugOpSubAugOpMulAugOpDivAugOpPowAugOpMod"
+
+var _AugOp_index = [...]uint8{0, 8, 16, 24, 32, 40, 48}
+
+func (i AugOp) String() string {
+	if i < 0 || i >= AugOp(len(_AugOp_index)-1) {
+		return "AugOp(" + strconv.FormatInt(int64(i), 10) + ")"
+	}
+	return _AugOp_name[_AugOp_index[i]:_AugOp_index[i+1]]
+}
+func _() {
+	// An "invalid array index" compiler error signifies that the constant values have changed.
+	// Re-run the stringer command to generate them again.
+	var x [1]struct{}
+	_ = x[BuiltinAtan2-0]
+	_ = x[BuiltinClose-1]
+	_ = x[BuiltinCos-2]
+	_ = x[BuiltinExp-3]
+	_ = x[BuiltinFflush-4]
+	_ = x[BuiltinFflushAll-5]
+	_ = x[BuiltinGsub-6]
+	_ = x[BuiltinIndex-7]
+	_ = x[BuiltinInt-8]
+	_ = x[BuiltinLength-9]
+	_ = x[BuiltinLengthArg-10]
+	_ = x[BuiltinLog-11]
+	_ = x[BuiltinMatch-12]
+	_ = x[BuiltinRand-13]
+	_ = x[BuiltinSin-14]
+	_ = x[BuiltinSqrt-15]
+	_ = x[BuiltinSrand-16]
+	_ = x[BuiltinSrandSeed-17]
+	_ = x[BuiltinSub-18]
+	_ = x[BuiltinSubstr-19]
+	_ = x[BuiltinSubstrLength-20]
+	_ = x[BuiltinSystem-21]
+	_ = x[BuiltinTolower-22]
+	_ = x[BuiltinToupper-23]
+}
+
+const _BuiltinOp_name = "BuiltinAtan2BuiltinCloseBuiltinCosBuiltinExpBuiltinFflushBuiltinFflushAllBuiltinGsubBuiltinIndexBuiltinIntBuiltinLengthBuiltinLengthArgBuiltinLogBuiltinMatchBuiltinRandBuiltinSinBuiltinSqrtBuiltinSrandBuiltinSrandSeedBuiltinSubBuiltinSubstrBuiltinSubstrLengthBuiltinSystemBuiltinTolowerBuiltinToupper"
+
+var _BuiltinOp_index = [...]uint16{0, 12, 24, 34, 44, 57, 73, 84, 96, 106, 119, 135, 145, 157, 168, 178, 189, 201, 217, 227, 240, 259, 272, 286, 300}
+
+func (i BuiltinOp) String() string {
+	if i < 0 || i >= BuiltinOp(len(_BuiltinOp_index)-1) {
+		return "BuiltinOp(" + strconv.FormatInt(int64(i), 10) + ")"
+	}
+	return _BuiltinOp_name[_BuiltinOp_index[i]:_BuiltinOp_index[i+1]]
+}
diff --git a/internal/compiler/opcodes.go b/internal/compiler/opcodes.go
new file mode 100644
index 0000000..2ba721a
--- /dev/null
+++ b/internal/compiler/opcodes.go
@@ -0,0 +1,175 @@
+package compiler
+
+//go:generate go run golang.org/x/tools/cmd/stringer@v0.1.8 -type=Opcode,AugOp,BuiltinOp
+
+// Opcode represents a single virtual machine instruction (or argument). The
+// comments beside each opcode show any arguments that instruction consumes.
+//
+// Normally this is called "bytecode", but I've avoided that term here as each
+// opcode is a 32-bit word, not an 8-bit byte.
+//
+// I tested various bit widths, and I believe 32 bit was the fastest, but also
+// means we don't have to worry about jump offsets overflowing. That's tested
+// in the compiler, but who's going to have an AWK program bigger than 2GB?
+type Opcode int32
+
+const (
+	Nop Opcode = iota
+
+	// Stack operations
+	Num // numIndex
+	Str // strIndex
+	Dupe
+	Drop
+	Swap
+
+	// Fetch a field, variable, or array item
+	Field
+	FieldInt    // index
+	Global      // index
+	Local       // index
+	Special     // index
+	ArrayGlobal // arrayIndex
+	ArrayLocal  // arrayIndex
+	InGlobal    // arrayIndex
+	InLocal     // arrayIndex
+
+	// Assign a field, variable, or array item
+	AssignField
+	AssignGlobal      // index
+	AssignLocal       // index
+	AssignSpecial     // index
+	AssignArrayGlobal // arrayIndex
+	AssignArrayLocal  // arrayIndex
+
+	// Delete statement
+	Delete    // arrayScope arrayIndex
+	DeleteAll // arrayScope arrayIndex
+
+	// Post-increment and post-decrement
+	IncrField       // amount
+	IncrGlobal      // amount index
+	IncrLocal       // amount index
+	IncrSpecial     // amount index
+	IncrArrayGlobal // amount arrayIndex
+	IncrArrayLocal  // amount arrayIndex
+
+	// Augmented assignment (also used for pre-increment and pre-decrement)
+	AugAssignField       // augOp
+	AugAssignGlobal      // augOp index
+	AugAssignLocal       // augOp index
+	AugAssignSpecial     // augOp index
+	AugAssignArrayGlobal // augOp arrayIndex
+	AugAssignArrayLocal  // augOp arrayIndex
+
+	// Stand-alone regex expression /foo/
+	Regex // regexIndex
+
+	// Multi-index concatenation
+	MultiIndex // num
+
+	// Binary operators
+	Add
+	Subtract
+	Multiply
+	Divide
+	Power
+	Modulo
+	Equals
+	NotEquals
+	Less
+	Greater
+	LessOrEqual
+	GreaterOrEqual
+	Concat
+	Match
+	NotMatch
+
+	// Unary operators
+	Not
+	UnaryMinus
+	UnaryPlus
+	Boolean
+
+	// Control flow
+	Jump               // offset
+	JumpFalse          // offset
+	JumpTrue           // offset
+	JumpEquals         // offset
+	JumpNotEquals      // offset
+	JumpLess           // offset
+	JumpGreater        // offset
+	JumpLessOrEqual    // offset
+	JumpGreaterOrEqual // offset
+	Next
+	Exit
+	ForIn // varScope varIndex arrayScope arrayIndex offset
+	BreakForIn
+
+	// Builtin functions
+	CallBuiltin  // builtinOp
+	CallSplit    // arrayScope arrayIndex
+	CallSplitSep // arrayScope arrayIndex
+	CallSprintf  // numArgs
+
+	// User and native functions
+	CallUser   // funcIndex numArrayArgs [arrayScope1 arrayIndex1 ...]
+	CallNative // funcIndex numArgs
+	Return
+	ReturnNull
+	Nulls // numNulls
+
+	// Print, printf, and getline
+	Print          // numArgs redirect
+	Printf         // numArgs redirect
+	Getline        // redirect
+	GetlineField   // redirect
+	GetlineGlobal  // redirect index
+	GetlineLocal   // redirect index
+	GetlineSpecial // redirect index
+	GetlineArray   // redirect arrayScope arrayIndex
+
+	EndOpcode
+)
+
+// AugOp represents an augmented assignment operation.
+type AugOp int32
+
+const (
+	AugOpAdd AugOp = iota
+	AugOpSub
+	AugOpMul
+	AugOpDiv
+	AugOpPow
+	AugOpMod
+)
+
+// BuiltinOp represents a builtin function call.
+type BuiltinOp int32
+
+const (
+	BuiltinAtan2 BuiltinOp = iota
+	BuiltinClose
+	BuiltinCos
+	BuiltinExp
+	BuiltinFflush
+	BuiltinFflushAll
+	BuiltinGsub
+	BuiltinIndex
+	BuiltinInt
+	BuiltinLength
+	BuiltinLengthArg
+	BuiltinLog
+	BuiltinMatch
+	BuiltinRand
+	BuiltinSin
+	BuiltinSqrt
+	BuiltinSrand
+	BuiltinSrandSeed
+	BuiltinSub
+	BuiltinSubstr
+	BuiltinSubstrLength
+	BuiltinSystem
+	BuiltinTolower
+	BuiltinToupper
+)
diff --git a/interp/functions.go b/interp/functions.go
index 04b528b..24f0473 100644
--- a/interp/functions.go
+++ b/interp/functions.go
@@ -1,4 +1,4 @@
-// Evaluate builtin and user-defined function calls
+// Call native Go functions; helpers for some builtin function calls.
 
 package interp
 
@@ -6,333 +6,19 @@ import (
 	"bytes"
 	"errors"
 	"fmt"
-	"io"
-	"math"
-	"os/exec"
 	"reflect"
 	"sort"
 	"strconv"
 	"strings"
-	"time"
 	"unicode/utf8"
 
 	"github.com/benhoyt/goawk/internal/ast"
 	. "github.com/benhoyt/goawk/lexer"
 )
 
-// Call builtin function specified by "op" with given args
-func (p *interp) callBuiltin(op Token, argExprs []ast.Expr) (value, error) {
-	// split() has an array arg (not evaluated) and [g]sub() have an
-	// lvalue arg, so handle them as special cases
-	switch op {
-	case F_SPLIT:
-		strValue, err := p.eval(argExprs[0])
-		if err != nil {
-			return null(), err
-		}
-		str := p.toString(strValue)
-		var fieldSep string
-		if len(argExprs) == 3 {
-			sepValue, err := p.eval(argExprs[2])
-			if err != nil {
-				return null(), err
-			}
-			fieldSep = p.toString(sepValue)
-		} else {
-			fieldSep = p.fieldSep
-		}
-		arrayExpr := argExprs[1].(*ast.ArrayExpr)
-		n, err := p.split(str, arrayExpr.Scope, arrayExpr.Index, fieldSep)
-		if err != nil {
-			return null(), err
-		}
-		return num(float64(n)), nil
-
-	case F_SUB, F_GSUB:
-		regexValue, err := p.eval(argExprs[0])
-		if err != nil {
-			return null(), err
-		}
-		regex := p.toString(regexValue)
-		replValue, err := p.eval(argExprs[1])
-		if err != nil {
-			return null(), err
-		}
-		repl := p.toString(replValue)
-		var in string
-		if len(argExprs) == 3 {
-			inValue, err := p.eval(argExprs[2])
-			if err != nil {
-				return null(), err
-			}
-			in = p.toString(inValue)
-		} else {
-			in = p.line
-		}
-		out, n, err := p.sub(regex, repl, in, op == F_GSUB)
-		if err != nil {
-			return null(), err
-		}
-		if len(argExprs) == 3 {
-			err := p.assign(argExprs[2], str(out))
-			if err != nil {
-				return null(), err
-			}
-		} else {
-			p.setLine(out, true)
-		}
-		return num(float64(n)), nil
-	}
-
-	// Now evaluate the argExprs (calls with up to 7 args don't
-	// require heap allocation)
-	args := make([]value, 0, 7)
-	for _, a := range argExprs {
-		arg, err := p.eval(a)
-		if err != nil {
-			return null(), err
-		}
-		args = append(args, arg)
-	}
-
-	// Then switch on the function for the ordinary functions
-	switch op {
-	case F_LENGTH:
-		switch len(args) {
-		case 0:
-			return num(float64(len(p.line))), nil
-		default:
-			return num(float64(len(p.toString(args[0])))), nil
-		}
-
-	case F_MATCH:
-		re, err := p.compileRegex(p.toString(args[1]))
-		if err != nil {
-			return null(), err
-		}
-		loc := re.FindStringIndex(p.toString(args[0]))
-		if loc == nil {
-			p.matchStart = 0
-			p.matchLength = -1
-			return num(0), nil
-		}
-		p.matchStart = loc[0] + 1
-		p.matchLength = loc[1] - loc[0]
-		return num(float64(p.matchStart)), nil
-
-	case F_SUBSTR:
-		s := p.toString(args[0])
-		pos := int(args[1].num())
-		if pos > len(s) {
-			pos = len(s) + 1
-		}
-		if pos < 1 {
-			pos = 1
-		}
-		maxLength := len(s) - pos + 1
-		length := maxLength
-		if len(args) == 3 {
-			length = int(args[2].num())
-			if length < 0 {
-				length = 0
-			}
-			if length > maxLength {
-				length = maxLength
-			}
-		}
-		return str(s[pos-1 : pos-1+length]), nil
-
-	case F_SPRINTF:
-		s, err := p.sprintf(p.toString(args[0]), args[1:])
-		if err != nil {
-			return null(), err
-		}
-		return str(s), nil
-
-	case F_INDEX:
-		s := p.toString(args[0])
-		substr := p.toString(args[1])
-		return num(float64(strings.Index(s, substr) + 1)), nil
-
-	case F_TOLOWER:
-		return str(strings.ToLower(p.toString(args[0]))), nil
-	case F_TOUPPER:
-		return str(strings.ToUpper(p.toString(args[0]))), nil
-
-	case F_ATAN2:
-		return num(math.Atan2(args[0].num(), args[1].num())), nil
-	case F_COS:
-		return num(math.Cos(args[0].num())), nil
-	case F_EXP:
-		return num(math.Exp(args[0].num())), nil
-	case F_INT:
-		return num(float64(int(args[0].num()))), nil
-	case F_LOG:
-		return num(math.Log(args[0].num())), nil
-	case F_SQRT:
-		return num(math.Sqrt(args[0].num())), nil
-	case F_RAND:
-		return num(p.random.Float64()), nil
-	case F_SIN:
-		return num(math.Sin(args[0].num())), nil
-
-	case F_SRAND:
-		prevSeed := p.randSeed
-		switch len(args) {
-		case 0:
-			p.random.Seed(time.Now().UnixNano())
-		case 1:
-			p.randSeed = args[0].num()
-			p.random.Seed(int64(math.Float64bits(p.randSeed)))
-		}
-		return num(prevSeed), nil
-
-	case F_SYSTEM:
-		if p.noExec {
-			return null(), newError("can't call system() due to NoExec")
-		}
-		cmdline := p.toString(args[0])
-		cmd := p.execShell(cmdline)
-		cmd.Stdout = p.output
-		cmd.Stderr = p.errorOutput
-		_ = p.flushAll() // ensure synchronization
-		err := cmd.Start()
-		if err != nil {
-			p.printErrorf("%s\n", err)
-			return num(-1), nil
-		}
-		err = cmd.Wait()
-		if err != nil {
-			if exitErr, ok := err.(*exec.ExitError); ok {
-				code := exitErr.ProcessState.ExitCode()
-				return num(float64(code)), nil
-			} else {
-				p.printErrorf("unexpected error running command %q: %v\n", cmdline, err)
-				return num(-1), nil
-			}
-		}
-		return num(0), nil
-
-	case F_CLOSE:
-		name := p.toString(args[0])
-		var c io.Closer = p.inputStreams[name]
-		if c != nil {
-			// Close input stream
-			delete(p.inputStreams, name)
-			err := c.Close()
-			if err != nil {
-				return num(-1), nil
-			}
-			return num(0), nil
-		}
-		c = p.outputStreams[name]
-		if c != nil {
-			// Close output stream
-			delete(p.outputStreams, name)
-			err := c.Close()
-			if err != nil {
-				return num(-1), nil
-			}
-			return num(0), nil
-		}
-		// Nothing to close
-		return num(-1), nil
-
-	case F_FFLUSH:
-		var name string
-		if len(args) > 0 {
-			name = p.toString(args[0])
-		}
-		var ok bool
-		if name != "" {
-			// Flush a single, named output stream
-			ok = p.flushStream(name)
-		} else {
-			// fflush() or fflush("") flushes all output streams
-			ok = p.flushAll()
-		}
-		if !ok {
-			return num(-1), nil
-		}
-		return num(0), nil
-
-	default:
-		// Shouldn't happen
-		panic(fmt.Sprintf("unexpected function: %s", op))
-	}
-}
-
-// Executes code using configured system shell
-func (p *interp) execShell(code string) *exec.Cmd {
-	executable := p.shellCommand[0]
-	args := p.shellCommand[1:]
-	args = append(args, code)
-	cmd := exec.Command(executable, args...)
-	return cmd
-}
-
-// Call user-defined function with given index and arguments, return
-// its return value (or null value if it doesn't return anything)
-func (p *interp) callUser(index int, args []ast.Expr) (value, error) {
-	f := p.program.Functions[index]
-
-	if p.callDepth >= maxCallDepth {
-		return null(), newError("calling %q exceeded maximum call depth of %d", f.Name, maxCallDepth)
-	}
-
-	// Evaluate the arguments and push them onto the locals stack
-	oldFrame := p.frame
-	newFrameStart := len(p.stack)
-	var arrays []int
-	for i, arg := range args {
-		if f.Arrays[i] {
-			a := arg.(*ast.VarExpr)
-			arrays = append(arrays, p.getArrayIndex(a.Scope, a.Index))
-		} else {
-			argValue, err := p.eval(arg)
-			if err != nil {
-				return null(), err
-			}
-			p.stack = append(p.stack, argValue)
-		}
-	}
-	// Push zero value for any additional parameters (it's valid to
-	// call a function with fewer arguments than it has parameters)
-	oldArraysLen := len(p.arrays)
-	for i := len(args); i < len(f.Params); i++ {
-		if f.Arrays[i] {
-			arrays = append(arrays, len(p.arrays))
-			p.arrays = append(p.arrays, make(map[string]value))
-		} else {
-			p.stack = append(p.stack, null())
-		}
-	}
-	p.frame = p.stack[newFrameStart:]
-	p.localArrays = append(p.localArrays, arrays)
-
-	// Execute the function!
-	p.callDepth++
-	err := p.executes(f.Body)
-	p.callDepth--
-
-	// Pop the locals off the stack
-	p.stack = p.stack[:newFrameStart]
-	p.frame = oldFrame
-	p.localArrays = p.localArrays[:len(p.localArrays)-1]
-	p.arrays = p.arrays[:oldArraysLen]
-
-	if r, ok := err.(returnValue); ok {
-		return r.Value, nil
-	}
-	if err != nil {
-		return null(), err
-	}
-	return null(), nil
-}
-
 // Call native-defined function with given name and arguments, return
 // its return value (or null value if it doesn't return anything).
-func (p *interp) callNative(index int, args []ast.Expr) (value, error) {
+func (p *interp) callNative(index int, args []value) (value, error) {
 	f := p.nativeFuncs[index]
 	minIn := len(f.in) // Minimum number of args we should pass
 	var variadicType reflect.Type
@@ -343,11 +29,7 @@ func (p *interp) callNative(index int, args []ast.Expr) (value, error) {
 
 	// Build list of args to pass to function
 	values := make([]reflect.Value, 0, 7) // up to 7 args won't require heap allocation
-	for i, arg := range args {
-		a, err := p.eval(arg)
-		if err != nil {
-			return null(), err
-		}
+	for i, a := range args {
 		var argType reflect.Type
 		if !f.isVariadic || i < len(f.in)-1 {
 			argType = f.in[i]
@@ -565,7 +247,7 @@ func (p *interp) split(s string, scope ast.VarScope, index int, fs string) (int,
 	if fs == " " {
 		parts = strings.Fields(s)
 	} else if s == "" {
-		// NF should be 0 on empty line
+		// Leave parts 0 length on empty string
 	} else if utf8.RuneCountInString(fs) <= 1 {
 		parts = strings.Split(s, fs)
 	} else {
@@ -579,7 +261,7 @@ func (p *interp) split(s string, scope ast.VarScope, index int, fs string) (int,
 	for i, part := range parts {
 		array[strconv.Itoa(i+1)] = numStr(part)
 	}
-	p.arrays[p.getArrayIndex(scope, index)] = array
+	p.arrays[p.arrayIndex(scope, index)] = array
 	return len(array), nil
 }
 
@@ -707,7 +389,7 @@ func (p *interp) sprintf(format string, args []value) (string, error) {
 		case 'f':
 			v = a.num()
 		case 'u':
-			v = uint32(a.num())
+			v = uint(a.num())
 		case 'c':
 			var c []byte
 			n, isStr := a.isTrueStr()
diff --git a/interp/interp.go b/interp/interp.go
index 7826ccd..dcf8989 100644
--- a/interp/interp.go
+++ b/interp/interp.go
@@ -24,15 +24,14 @@ import (
 	"unicode/utf8"
 
 	"github.com/benhoyt/goawk/internal/ast"
-	. "github.com/benhoyt/goawk/lexer"
+	"github.com/benhoyt/goawk/internal/compiler"
 	"github.com/benhoyt/goawk/parser"
 )
 
 var (
-	errExit     = errors.New("exit")
-	errBreak    = errors.New("break")
-	errContinue = errors.New("continue")
-	errNext     = errors.New("next")
+	errExit  = errors.New("exit")
+	errBreak = errors.New("break")
+	errNext  = errors.New("next")
 
 	crlfNewline = runtime.GOOS == "windows"
 	varRegex    = regexp.MustCompile(`^([_a-zA-Z][_a-zA-Z0-9]*)=(.*)`)
@@ -81,6 +80,7 @@ type interp struct {
 	// Scalars, arrays, and function state
 	globals     []value
 	stack       []value
+	sp          int
 	frame       []value
 	arrays      []map[string]value
 	localArrays [][]int
@@ -113,8 +113,14 @@ type interp struct {
 	matchLength      int
 	matchStart       int
 
+	// Parsed program, compiled functions and constants
+	program   *parser.Program
+	functions []compiler.Function
+	nums      []float64
+	strs      []string
+	regexes   []*regexp.Regexp
+
 	// Misc pieces of state
-	program     *parser.Program
 	random      *rand.Rand
 	randSeed    float64
 	exitStatus  int
@@ -217,11 +223,17 @@ func ExecProgram(program *parser.Program, config *Config) (int, error) {
 		return 0, newError("length of config.Environ must be a multiple of 2, not %d", len(config.Environ))
 	}
 
-	p := &interp{program: program}
+	p := &interp{
+		program:   program,
+		functions: program.Compiled.Functions,
+		nums:      program.Compiled.Nums,
+		strs:      program.Compiled.Strs,
+		regexes:   program.Compiled.Regexes,
+	}
 
-	// Allocate memory for variables
+	// Allocate memory for variables and virtual machine stack
 	p.globals = make([]value, len(program.Scalars))
-	p.stack = make([]value, 0, initialStackSize)
+	p.stack = make([]value, initialStackSize)
 	p.arrays = make([]map[string]value, len(program.Arrays), len(program.Arrays)+initialStackSize)
 	for i := 0; i < len(program.Arrays); i++ {
 		p.arrays[i] = make(map[string]value)
@@ -309,8 +321,8 @@ func ExecProgram(program *parser.Program, config *Config) (int, error) {
 	p.scanners = make(map[string]*bufio.Scanner)
 	defer p.closeAll()
 
-	// Execute the program! BEGIN, then pattern/actions, then END
-	err = p.execBeginEnd(program.Begin)
+	// Execute the program: BEGIN, then pattern/actions, then END
+	err = p.execute(program.Compiled.Begin)
 	if err != nil && err != errExit {
 		return 0, err
 	}
@@ -318,12 +330,12 @@ func ExecProgram(program *parser.Program, config *Config) (int, error) {
 		return p.exitStatus, nil
 	}
 	if err != errExit {
-		err = p.execActions(program.Actions)
+		err = p.execActions(program.Compiled.Actions)
 		if err != nil && err != errExit {
 			return 0, err
 		}
 	}
-	err = p.execBeginEnd(program.End)
+	err = p.execute(program.Compiled.End)
 	if err != nil && err != errExit {
 		return 0, err
 	}
@@ -349,19 +361,8 @@ func Exec(source, fieldSep string, input io.Reader, output io.Writer) error {
 	return err
 }
 
-// Execute BEGIN or END blocks (may be multiple)
-func (p *interp) execBeginEnd(beginEnd []ast.Stmts) error {
-	for _, statements := range beginEnd {
-		err := p.executes(statements)
-		if err != nil {
-			return err
-		}
-	}
-	return nil
-}
-
 // Execute pattern-action blocks (may be multiple)
-func (p *interp) execActions(actions []ast.Action) error {
+func (p *interp) execActions(actions []compiler.Action) error {
 	inRange := make([]bool, len(actions))
 lineLoop:
 	for {
@@ -385,27 +386,27 @@ lineLoop:
 				matched = true
 			case 1:
 				// Single boolean pattern
-				v, err := p.eval(action.Pattern[0])
+				err := p.execute(action.Pattern[0])
 				if err != nil {
 					return err
 				}
-				matched = v.boolean()
+				matched = p.pop().boolean()
 			case 2:
 				// Range pattern (matches between start and stop lines)
 				if !inRange[i] {
-					v, err := p.eval(action.Pattern[0])
+					err := p.execute(action.Pattern[0])
 					if err != nil {
 						return err
 					}
-					inRange[i] = v.boolean()
+					inRange[i] = p.pop().boolean()
 				}
 				matched = inRange[i]
 				if inRange[i] {
-					v, err := p.eval(action.Pattern[1])
+					err := p.execute(action.Pattern[1])
 					if err != nil {
 						return err
 					}
-					inRange[i] = !v.boolean()
+					inRange[i] = !p.pop().boolean()
 				}
 			}
 			if !matched {
@@ -413,7 +414,7 @@ lineLoop:
 			}
 
 			// No action is equivalent to { print $0 }
-			if action.Stmts == nil {
+			if len(action.Body) == 0 {
 				err := p.printLine(p.output, p.line)
 				if err != nil {
 					return err
@@ -422,7 +423,7 @@ lineLoop:
 			}
 
 			// Execute the body statements
-			err := p.executes(action.Stmts)
+			err := p.execute(action.Body)
 			if err == errNext {
 				// "next" statement skips straight to next line
 				continue lineLoop
@@ -435,528 +436,6 @@ lineLoop:
 	return nil
 }
 
-// Execute a block of multiple statements
-func (p *interp) executes(stmts ast.Stmts) error {
-	for _, s := range stmts {
-		err := p.execute(s)
-		if err != nil {
-			return err
-		}
-	}
-	return nil
-}
-
-// Execute a single statement
-func (p *interp) execute(stmt ast.Stmt) error {
-	switch s := stmt.(type) {
-	case *ast.ExprStmt:
-		// Expression statement: simply throw away the expression value
-		_, err := p.eval(s.Expr)
-		return err
-
-	case *ast.PrintStmt:
-		// Print OFS-separated args followed by ORS (usually newline)
-		var line string
-		if len(s.Args) > 0 {
-			strs := make([]string, len(s.Args))
-			for i, a := range s.Args {
-				v, err := p.eval(a)
-				if err != nil {
-					return err
-				}
-				strs[i] = v.str(p.outputFormat)
-			}
-			line = strings.Join(strs, p.outputFieldSep)
-		} else {
-			// "print" with no args is equivalent to "print $0"
-			line = p.line
-		}
-		output, err := p.getOutputStream(s.Redirect, s.Dest)
-		if err != nil {
-			return err
-		}
-		return p.printLine(output, line)
-
-	case *ast.PrintfStmt:
-		// printf(fmt, arg1, arg2, ...): uses our version of sprintf
-		// to build the formatted string and then print that
-		formatValue, err := p.eval(s.Args[0])
-		if err != nil {
-			return err
-		}
-		format := p.toString(formatValue)
-		args := make([]value, len(s.Args)-1)
-		for i, a := range s.Args[1:] {
-			args[i], err = p.eval(a)
-			if err != nil {
-				return err
-			}
-		}
-		output, err := p.getOutputStream(s.Redirect, s.Dest)
-		if err != nil {
-			return err
-		}
-		str, err := p.sprintf(format, args)
-		if err != nil {
-			return err
-		}
-		err = writeOutput(output, str)
-		if err != nil {
-			return err
-		}
-
-	case *ast.IfStmt:
-		v, err := p.eval(s.Cond)
-		if err != nil {
-			return err
-		}
-		if v.boolean() {
-			return p.executes(s.Body)
-		} else {
-			// Doesn't do anything if s.Else is nil
-			return p.executes(s.Else)
-		}
-
-	case *ast.ForStmt:
-		// C-like for loop with pre-statement, cond, and post-statement
-		if s.Pre != nil {
-			err := p.execute(s.Pre)
-			if err != nil {
-				return err
-			}
-		}
-		for {
-			if s.Cond != nil {
-				v, err := p.eval(s.Cond)
-				if err != nil {
-					return err
-				}
-				if !v.boolean() {
-					break
-				}
-			}
-			err := p.executes(s.Body)
-			if err == errBreak {
-				break
-			}
-			if err != nil && err != errContinue {
-				return err
-			}
-			if s.Post != nil {
-				err := p.execute(s.Post)
-				if err != nil {
-					return err
-				}
-			}
-		}
-
-	case *ast.ForInStmt:
-		// Foreach-style "for (key in array)" loop
-		array := p.arrays[p.getArrayIndex(s.Array.Scope, s.Array.Index)]
-		for index := range array {
-			err := p.setVar(s.Var.Scope, s.Var.Index, str(index))
-			if err != nil {
-				return err
-			}
-			err = p.executes(s.Body)
-			if err == errBreak {
-				break
-			}
-			if err == errContinue {
-				continue
-			}
-			if err != nil {
-				return err
-			}
-		}
-
-	case *ast.ReturnStmt:
-		// Return statement uses special error value which is "caught"
-		// by the callUser function
-		var v value
-		if s.Value != nil {
-			var err error
-			v, err = p.eval(s.Value)
-			if err != nil {
-				return err
-			}
-		}
-		return returnValue{v}
-
-	case *ast.WhileStmt:
-		// Simple "while (cond)" loop
-		for {
-			v, err := p.eval(s.Cond)
-			if err != nil {
-				return err
-			}
-			if !v.boolean() {
-				break
-			}
-			err = p.executes(s.Body)
-			if err == errBreak {
-				break
-			}
-			if err == errContinue {
-				continue
-			}
-			if err != nil {
-				return err
-			}
-		}
-
-	case *ast.DoWhileStmt:
-		// Do-while loop (tests condition after executing body)
-		for {
-			err := p.executes(s.Body)
-			if err == errBreak {
-				break
-			}
-			if err == errContinue {
-				continue
-			}
-			if err != nil {
-				return err
-			}
-			v, err := p.eval(s.Cond)
-			if err != nil {
-				return err
-			}
-			if !v.boolean() {
-				break
-			}
-		}
-
-	// Break, continue, next, and exit statements
-	case *ast.BreakStmt:
-		return errBreak
-	case *ast.ContinueStmt:
-		return errContinue
-	case *ast.NextStmt:
-		return errNext
-	case *ast.ExitStmt:
-		if s.Status != nil {
-			status, err := p.eval(s.Status)
-			if err != nil {
-				return err
-			}
-			p.exitStatus = int(status.num())
-		}
-		// Return special errExit value "caught" by top-level executor
-		return errExit
-
-	case *ast.DeleteStmt:
-		if len(s.Index) > 0 {
-			// Delete single key from array
-			index, err := p.evalIndex(s.Index)
-			if err != nil {
-				return err
-			}
-			array := p.arrays[p.getArrayIndex(s.Array.Scope, s.Array.Index)]
-			delete(array, index) // Does nothing if key isn't present
-		} else {
-			// Delete entire array
-			array := p.arrays[p.getArrayIndex(s.Array.Scope, s.Array.Index)]
-			for k := range array {
-				delete(array, k)
-			}
-		}
-
-	case *ast.BlockStmt:
-		// Nested block (just syntax, doesn't do anything)
-		return p.executes(s.Body)
-
-	default:
-		// Should never happen
-		panic(fmt.Sprintf("unexpected stmt type: %T", stmt))
-	}
-	return nil
-}
-
-// Evaluate a single expression, return expression value and error
-func (p *interp) eval(expr ast.Expr) (value, error) {
-	switch e := expr.(type) {
-	case *ast.NumExpr:
-		// Number literal
-		return num(e.Value), nil
-
-	case *ast.StrExpr:
-		// String literal
-		return str(e.Value), nil
-
-	case *ast.FieldExpr:
-		// $n field expression
-		index, err := p.eval(e.Index)
-		if err != nil {
-			return null(), err
-		}
-		return p.getField(int(index.num()))
-
-	case *ast.VarExpr:
-		// Variable read expression (scope is global, local, or special)
-		return p.getVar(e.Scope, e.Index), nil
-
-	case *ast.RegExpr:
-		// Stand-alone /regex/ is equivalent to: $0 ~ /regex/
-		re, err := p.compileRegex(e.Regex)
-		if err != nil {
-			return null(), err
-		}
-		return boolean(re.MatchString(p.line)), nil
-
-	case *ast.BinaryExpr:
-		// Binary expression. Note that && and || are special cases
-		// as they're short-circuit operators.
-		left, err := p.eval(e.Left)
-		if err != nil {
-			return null(), err
-		}
-		switch e.Op {
-		case AND:
-			if !left.boolean() {
-				return num(0), nil
-			}
-			right, err := p.eval(e.Right)
-			if err != nil {
-				return null(), err
-			}
-			return boolean(right.boolean()), nil
-		case OR:
-			if left.boolean() {
-				return num(1), nil
-			}
-			right, err := p.eval(e.Right)
-			if err != nil {
-				return null(), err
-			}
-			return boolean(right.boolean()), nil
-		default:
-			right, err := p.eval(e.Right)
-			if err != nil {
-				return null(), err
-			}
-			return p.evalBinary(e.Op, left, right)
-		}
-
-	case *ast.IncrExpr:
-		// Pre-increment, post-increment, pre-decrement, post-decrement
-
-		// First evaluate the expression, but remember array or field
-		// index, so we don't evaluate part of the expression twice
-		exprValue, arrayIndex, fieldIndex, err := p.evalForAugAssign(e.Expr)
-		if err != nil {
-			return null(), err
-		}
-
-		// Then convert to number and increment or decrement
-		exprNum := exprValue.num()
-		var incr float64
-		if e.Op == INCR {
-			incr = exprNum + 1
-		} else {
-			incr = exprNum - 1
-		}
-		incrValue := num(incr)
-
-		// Finally, assign back to expression and return the correct value
-		err = p.assignAug(e.Expr, arrayIndex, fieldIndex, incrValue)
-		if err != nil {
-			return null(), err
-		}
-		if e.Pre {
-			return incrValue, nil
-		} else {
-			return num(exprNum), nil
-		}
-
-	case *ast.AssignExpr:
-		// Assignment expression (returns right-hand side)
-		right, err := p.eval(e.Right)
-		if err != nil {
-			return null(), err
-		}
-		err = p.assign(e.Left, right)
-		if err != nil {
-			return null(), err
-		}
-		return right, nil
-
-	case *ast.AugAssignExpr:
-		// Augmented assignment like += (returns right-hand side)
-		right, err := p.eval(e.Right)
-		if err != nil {
-			return null(), err
-		}
-		left, arrayIndex, fieldIndex, err := p.evalForAugAssign(e.Left)
-		if err != nil {
-			return null(), err
-		}
-		right, err = p.evalBinary(e.Op, left, right)
-		if err != nil {
-			return null(), err
-		}
-		err = p.assignAug(e.Left, arrayIndex, fieldIndex, right)
-		if err != nil {
-			return null(), err
-		}
-		return right, nil
-
-	case *ast.CondExpr:
-		// C-like ?: ternary conditional operator
-		cond, err := p.eval(e.Cond)
-		if err != nil {
-			return null(), err
-		}
-		if cond.boolean() {
-			return p.eval(e.True)
-		} else {
-			return p.eval(e.False)
-		}
-
-	case *ast.IndexExpr:
-		// Read value from array by index
-		index, err := p.evalIndex(e.Index)
-		if err != nil {
-			return null(), err
-		}
-		return p.getArrayValue(e.Array.Scope, e.Array.Index, index), nil
-
-	case *ast.CallExpr:
-		// Call a builtin function
-		return p.callBuiltin(e.Func, e.Args)
-
-	case *ast.UnaryExpr:
-		// Unary ! or + or -
-		v, err := p.eval(e.Value)
-		if err != nil {
-			return null(), err
-		}
-		return p.evalUnary(e.Op, v), nil
-
-	case *ast.InExpr:
-		// "key in array" expression
-		index, err := p.evalIndex(e.Index)
-		if err != nil {
-			return null(), err
-		}
-		array := p.arrays[p.getArrayIndex(e.Array.Scope, e.Array.Index)]
-		_, ok := array[index]
-		return boolean(ok), nil
-
-	case *ast.UserCallExpr:
-		// Call user-defined or native Go function
-		if e.Native {
-			return p.callNative(e.Index, e.Args)
-		} else {
-			return p.callUser(e.Index, e.Args)
-		}
-
-	case *ast.GetlineExpr:
-		// Getline: read line from input
-		var line string
-		switch {
-		case e.Command != nil:
-			nameValue, err := p.eval(e.Command)
-			if err != nil {
-				return null(), err
-			}
-			name := p.toString(nameValue)
-			scanner, err := p.getInputScannerPipe(name)
-			if err != nil {
-				return null(), err
-			}
-			if !scanner.Scan() {
-				if err := scanner.Err(); err != nil {
-					return num(-1), nil
-				}
-				return num(0), nil
-			}
-			line = scanner.Text()
-		case e.File != nil:
-			nameValue, err := p.eval(e.File)
-			if err != nil {
-				return null(), err
-			}
-			name := p.toString(nameValue)
-			scanner, err := p.getInputScannerFile(name)
-			if err != nil {
-				if _, ok := err.(*os.PathError); ok {
-					// File not found is not a hard error, getline just returns -1.
-					// See: https://github.com/benhoyt/goawk/issues/41
-					return num(-1), nil
-				}
-				return null(), err
-			}
-			if !scanner.Scan() {
-				if err := scanner.Err(); err != nil {
-					return num(-1), nil
-				}
-				return num(0), nil
-			}
-			line = scanner.Text()
-		default:
-			p.flushOutputAndError() // Flush output in case they've written a prompt
-			var err error
-			line, err = p.nextLine()
-			if err == io.EOF {
-				return num(0), nil
-			}
-			if err != nil {
-				return num(-1), nil
-			}
-		}
-		if e.Target != nil {
-			err := p.assign(e.Target, numStr(line))
-			if err != nil {
-				return null(), err
-			}
-		} else {
-			p.setLine(line, false)
-		}
-		return num(1), nil
-
-	default:
-		// Should never happen
-		panic(fmt.Sprintf("unexpected expr type: %T", expr))
-	}
-}
-
-func (p *interp) evalForAugAssign(expr ast.Expr) (v value, arrayIndex string, fieldIndex int, err error) {
-	switch expr := expr.(type) {
-	case *ast.VarExpr:
-		v = p.getVar(expr.Scope, expr.Index)
-	case *ast.IndexExpr:
-		arrayIndex, err = p.evalIndex(expr.Index)
-		if err != nil {
-			return null(), "", 0, err
-		}
-		v = p.getArrayValue(expr.Array.Scope, expr.Array.Index, arrayIndex)
-	case *ast.FieldExpr:
-		index, err := p.eval(expr.Index)
-		if err != nil {
-			return null(), "", 0, err
-		}
-		fieldIndex = int(index.num())
-		v, err = p.getField(fieldIndex)
-		if err != nil {
-			return null(), "", 0, err
-		}
-	}
-	return v, arrayIndex, fieldIndex, nil
-}
-
-func (p *interp) assignAug(expr ast.Expr, arrayIndex string, fieldIndex int, v value) error {
-	switch expr := expr.(type) {
-	case *ast.VarExpr:
-		return p.setVar(expr.Scope, expr.Index, v)
-	case *ast.IndexExpr:
-		p.setArrayValue(expr.Array.Scope, expr.Array.Index, arrayIndex, v)
-	default: // *FieldExpr
-		return p.setField(fieldIndex, p.toString(v))
-	}
-	return nil
-}
-
 // Get a variable's value by index in given scope
 func (p *interp) getVar(scope ast.VarScope, index int) value {
 	switch scope {
@@ -965,41 +444,46 @@ func (p *interp) getVar(scope ast.VarScope, index int) value {
 	case ast.ScopeLocal:
 		return p.frame[index]
 	default: // ScopeSpecial
-		switch index {
-		case ast.V_NF:
-			p.ensureFields()
-			return num(float64(p.numFields))
-		case ast.V_NR:
-			return num(float64(p.lineNum))
-		case ast.V_RLENGTH:
-			return num(float64(p.matchLength))
-		case ast.V_RSTART:
-			return num(float64(p.matchStart))
-		case ast.V_FNR:
-			return num(float64(p.fileLineNum))
-		case ast.V_ARGC:
-			return num(float64(p.argc))
-		case ast.V_CONVFMT:
-			return str(p.convertFormat)
-		case ast.V_FILENAME:
-			return p.filename
-		case ast.V_FS:
-			return str(p.fieldSep)
-		case ast.V_OFMT:
-			return str(p.outputFormat)
-		case ast.V_OFS:
-			return str(p.outputFieldSep)
-		case ast.V_ORS:
-			return str(p.outputRecordSep)
-		case ast.V_RS:
-			return str(p.recordSep)
-		case ast.V_RT:
-			return str(p.recordTerminator)
-		case ast.V_SUBSEP:
-			return str(p.subscriptSep)
-		default:
-			panic(fmt.Sprintf("unexpected special variable index: %d", index))
-		}
+		return p.getSpecial(index)
+	}
+}
+
+// Get a special variable by index
+func (p *interp) getSpecial(index int) value {
+	switch index {
+	case ast.V_NF:
+		p.ensureFields()
+		return num(float64(p.numFields))
+	case ast.V_NR:
+		return num(float64(p.lineNum))
+	case ast.V_RLENGTH:
+		return num(float64(p.matchLength))
+	case ast.V_RSTART:
+		return num(float64(p.matchStart))
+	case ast.V_FNR:
+		return num(float64(p.fileLineNum))
+	case ast.V_ARGC:
+		return num(float64(p.argc))
+	case ast.V_CONVFMT:
+		return str(p.convertFormat)
+	case ast.V_FILENAME:
+		return p.filename
+	case ast.V_FS:
+		return str(p.fieldSep)
+	case ast.V_OFMT:
+		return str(p.outputFormat)
+	case ast.V_OFS:
+		return str(p.outputFieldSep)
+	case ast.V_ORS:
+		return str(p.outputRecordSep)
+	case ast.V_RS:
+		return str(p.recordSep)
+	case ast.V_RT:
+		return str(p.recordTerminator)
+	case ast.V_SUBSEP:
+		return str(p.subscriptSep)
+	default:
+		panic(fmt.Sprintf("unexpected special variable index: %d", index))
 	}
 }
 
@@ -1007,107 +491,99 @@ func (p *interp) getVar(scope ast.VarScope, index int) value {
 func (p *interp) setVarByName(name, value string) error {
 	index := ast.SpecialVarIndex(name)
 	if index > 0 {
-		return p.setVar(ast.ScopeSpecial, index, numStr(value))
+		return p.setSpecial(index, numStr(value))
 	}
 	index, ok := p.program.Scalars[name]
 	if ok {
-		return p.setVar(ast.ScopeGlobal, index, numStr(value))
+		p.globals[index] = numStr(value)
+		return nil
 	}
 	// Ignore variables that aren't defined in program
 	return nil
 }
 
-// Set a variable by index in given scope to given value
-func (p *interp) setVar(scope ast.VarScope, index int, v value) error {
-	switch scope {
-	case ast.ScopeGlobal:
-		p.globals[index] = v
-		return nil
-	case ast.ScopeLocal:
-		p.frame[index] = v
-		return nil
-	default: // ScopeSpecial
-		switch index {
-		case ast.V_NF:
-			numFields := int(v.num())
-			if numFields < 0 {
-				return newError("NF set to negative value: %d", numFields)
-			}
-			if numFields > maxFieldIndex {
-				return newError("NF set too large: %d", numFields)
-			}
-			p.ensureFields()
-			p.numFields = numFields
-			if p.numFields < len(p.fields) {
-				p.fields = p.fields[:p.numFields]
-				p.fieldsIsTrueStr = p.fieldsIsTrueStr[:p.numFields]
-			}
-			for i := len(p.fields); i < p.numFields; i++ {
-				p.fields = append(p.fields, "")
-				p.fieldsIsTrueStr = append(p.fieldsIsTrueStr, false)
-			}
-			p.line = strings.Join(p.fields, p.outputFieldSep)
-			p.lineIsTrueStr = true
-		case ast.V_NR:
-			p.lineNum = int(v.num())
-		case ast.V_RLENGTH:
-			p.matchLength = int(v.num())
-		case ast.V_RSTART:
-			p.matchStart = int(v.num())
-		case ast.V_FNR:
-			p.fileLineNum = int(v.num())
-		case ast.V_ARGC:
-			p.argc = int(v.num())
-		case ast.V_CONVFMT:
-			p.convertFormat = p.toString(v)
-		case ast.V_FILENAME:
-			p.filename = v
-		case ast.V_FS:
-			p.fieldSep = p.toString(v)
-			if utf8.RuneCountInString(p.fieldSep) > 1 { // compare to interp.ensureFields
-				re, err := regexp.Compile(p.fieldSep)
-				if err != nil {
-					return newError("invalid regex %q: %s", p.fieldSep, err)
-				}
-				p.fieldSepRegex = re
-			}
-		case ast.V_OFMT:
-			p.outputFormat = p.toString(v)
-		case ast.V_OFS:
-			p.outputFieldSep = p.toString(v)
-		case ast.V_ORS:
-			p.outputRecordSep = p.toString(v)
-		case ast.V_RS:
-			p.recordSep = p.toString(v)
-			switch { // compare to interp.newScanner
-			case len(p.recordSep) <= 1:
-				// Simple cases use specialized splitters, not regex
-			case utf8.RuneCountInString(p.recordSep) == 1:
-				// Multi-byte unicode char falls back to regex splitter
-				sep := regexp.QuoteMeta(p.recordSep) // not strictly necessary as no multi-byte chars are regex meta chars
-				p.recordSepRegex = regexp.MustCompile(sep)
-			default:
-				re, err := regexp.Compile(p.recordSep)
-				if err != nil {
-					return newError("invalid regex %q: %s", p.recordSep, err)
-				}
-				p.recordSepRegex = re
-			}
-		case ast.V_RT:
-			p.recordTerminator = p.toString(v)
-		case ast.V_SUBSEP:
-			p.subscriptSep = p.toString(v)
+// Set special variable by index to given value
+func (p *interp) setSpecial(index int, v value) error {
+	switch index {
+	case ast.V_NF:
+		numFields := int(v.num())
+		if numFields < 0 {
+			return newError("NF set to negative value: %d", numFields)
+		}
+		if numFields > maxFieldIndex {
+			return newError("NF set too large: %d", numFields)
+		}
+		p.ensureFields()
+		p.numFields = numFields
+		if p.numFields < len(p.fields) {
+			p.fields = p.fields[:p.numFields]
+			p.fieldsIsTrueStr = p.fieldsIsTrueStr[:p.numFields]
+		}
+		for i := len(p.fields); i < p.numFields; i++ {
+			p.fields = append(p.fields, "")
+			p.fieldsIsTrueStr = append(p.fieldsIsTrueStr, false)
+		}
+		p.line = strings.Join(p.fields, p.outputFieldSep)
+		p.lineIsTrueStr = true
+	case ast.V_NR:
+		p.lineNum = int(v.num())
+	case ast.V_RLENGTH:
+		p.matchLength = int(v.num())
+	case ast.V_RSTART:
+		p.matchStart = int(v.num())
+	case ast.V_FNR:
+		p.fileLineNum = int(v.num())
+	case ast.V_ARGC:
+		p.argc = int(v.num())
+	case ast.V_CONVFMT:
+		p.convertFormat = p.toString(v)
+	case ast.V_FILENAME:
+		p.filename = v
+	case ast.V_FS:
+		p.fieldSep = p.toString(v)
+		if utf8.RuneCountInString(p.fieldSep) > 1 { // compare to interp.ensureFields
+			re, err := regexp.Compile(p.fieldSep)
+			if err != nil {
+				return newError("invalid regex %q: %s", p.fieldSep, err)
+			}
+			p.fieldSepRegex = re
+		}
+	case ast.V_OFMT:
+		p.outputFormat = p.toString(v)
+	case ast.V_OFS:
+		p.outputFieldSep = p.toString(v)
+	case ast.V_ORS:
+		p.outputRecordSep = p.toString(v)
+	case ast.V_RS:
+		p.recordSep = p.toString(v)
+		switch { // compare to interp.newScanner
+		case len(p.recordSep) <= 1:
+			// Simple cases use specialized splitters, not regex
+		case utf8.RuneCountInString(p.recordSep) == 1:
+			// Multi-byte unicode char falls back to regex splitter
+			sep := regexp.QuoteMeta(p.recordSep) // not strictly necessary as no multi-byte chars are regex meta chars
+			p.recordSepRegex = regexp.MustCompile(sep)
 		default:
-			panic(fmt.Sprintf("unexpected special variable index: %d", index))
+			re, err := regexp.Compile(p.recordSep)
+			if err != nil {
+				return newError("invalid regex %q: %s", p.recordSep, err)
+			}
+			p.recordSepRegex = re
 		}
-		return nil
+	case ast.V_RT:
+		p.recordTerminator = p.toString(v)
+	case ast.V_SUBSEP:
+		p.subscriptSep = p.toString(v)
+	default:
+		panic(fmt.Sprintf("unexpected special variable index: %d", index))
 	}
+	return nil
 }
 
 // Determine the index of given array into the p.arrays slice. Global
 // arrays are just at p.arrays[index], local arrays have to be looked
 // up indirectly.
-func (p *interp) getArrayIndex(scope ast.VarScope, index int) int {
+func (p *interp) arrayIndex(scope ast.VarScope, index int) int {
 	if scope == ast.ScopeGlobal {
 		return index
 	} else {
@@ -1115,24 +591,20 @@ func (p *interp) getArrayIndex(scope ast.VarScope, index int) int {
 	}
 }
 
-// Get a value from given array by key (index)
-func (p *interp) getArrayValue(scope ast.VarScope, arrayIndex int, index string) value {
-	resolved := p.getArrayIndex(scope, arrayIndex)
-	array := p.arrays[resolved]
-	v, ok := array[index]
-	if !ok {
-		// Strangely, per the POSIX spec, "Any other reference to a
-		// nonexistent array element [apart from "in" expressions]
-		// shall automatically create it."
-		array[index] = v
-	}
-	return v
+// Return array with given scope and index.
+func (p *interp) array(scope ast.VarScope, index int) map[string]value {
+	return p.arrays[p.arrayIndex(scope, index)]
+}
+
+// Return local array with given index.
+func (p *interp) localArray(index int) map[string]value {
+	return p.arrays[p.localArrays[len(p.localArrays)-1][index]]
 }
 
 // Set a value in given array by key (index)
 func (p *interp) setArrayValue(scope ast.VarScope, arrayIndex int, index string, v value) {
-	resolved := p.getArrayIndex(scope, arrayIndex)
-	p.arrays[resolved][index] = v
+	array := p.array(scope, arrayIndex)
+	array[index] = v
 }
 
 // Get the value of given numbered field, equivalent to "$index"
@@ -1204,159 +676,3 @@ func (p *interp) compileRegex(regex string) (*regexp.Regexp, error) {
 	}
 	return re, nil
 }
-
-// Evaluate simple binary expression and return result
-func (p *interp) evalBinary(op Token, l, r value) (value, error) {
-	// Note: cases are ordered (very roughly) in order of frequency
-	// of occurrence for performance reasons. Benchmark on common code
-	// before changing the order.
-	switch op {
-	case ADD:
-		return num(l.num() + r.num()), nil
-	case SUB:
-		return num(l.num() - r.num()), nil
-	case EQUALS:
-		ln, lIsStr := l.isTrueStr()
-		rn, rIsStr := r.isTrueStr()
-		if lIsStr || rIsStr {
-			return boolean(p.toString(l) == p.toString(r)), nil
-		} else {
-			return boolean(ln == rn), nil
-		}
-	case LESS:
-		ln, lIsStr := l.isTrueStr()
-		rn, rIsStr := r.isTrueStr()
-		if lIsStr || rIsStr {
-			return boolean(p.toString(l) < p.toString(r)), nil
-		} else {
-			return boolean(ln < rn), nil
-		}
-	case LTE:
-		ln, lIsStr := l.isTrueStr()
-		rn, rIsStr := r.isTrueStr()
-		if lIsStr || rIsStr {
-			return boolean(p.toString(l) <= p.toString(r)), nil
-		} else {
-			return boolean(ln <= rn), nil
-		}
-	case CONCAT:
-		return str(p.toString(l) + p.toString(r)), nil
-	case MUL:
-		return num(l.num() * r.num()), nil
-	case DIV:
-		rf := r.num()
-		if rf == 0.0 {
-			return null(), newError("division by zero")
-		}
-		return num(l.num() / rf), nil
-	case GREATER:
-		ln, lIsStr := l.isTrueStr()
-		rn, rIsStr := r.isTrueStr()
-		if lIsStr || rIsStr {
-			return boolean(p.toString(l) > p.toString(r)), nil
-		} else {
-			return boolean(ln > rn), nil
-		}
-	case GTE:
-		ln, lIsStr := l.isTrueStr()
-		rn, rIsStr := r.isTrueStr()
-		if lIsStr || rIsStr {
-			return boolean(p.toString(l) >= p.toString(r)), nil
-		} else {
-			return boolean(ln >= rn), nil
-		}
-	case NOT_EQUALS:
-		ln, lIsStr := l.isTrueStr()
-		rn, rIsStr := r.isTrueStr()
-		if lIsStr || rIsStr {
-			return boolean(p.toString(l) != p.toString(r)), nil
-		} else {
-			return boolean(ln != rn), nil
-		}
-	case MATCH:
-		re, err := p.compileRegex(p.toString(r))
-		if err != nil {
-			return null(), err
-		}
-		matched := re.MatchString(p.toString(l))
-		return boolean(matched), nil
-	case NOT_MATCH:
-		re, err := p.compileRegex(p.toString(r))
-		if err != nil {
-			return null(), err
-		}
-		matched := re.MatchString(p.toString(l))
-		return boolean(!matched), nil
-	case POW:
-		return num(math.Pow(l.num(), r.num())), nil
-	case MOD:
-		rf := r.num()
-		if rf == 0.0 {
-			return null(), newError("division by zero in mod")
-		}
-		return num(math.Mod(l.num(), rf)), nil
-	default:
-		panic(fmt.Sprintf("unexpected binary operation: %s", op))
-	}
-}
-
-// Evaluate unary expression and return result
-func (p *interp) evalUnary(op Token, v value) value {
-	switch op {
-	case SUB:
-		return num(-v.num())
-	case NOT:
-		return boolean(!v.boolean())
-	case ADD:
-		return num(v.num())
-	default:
-		panic(fmt.Sprintf("unexpected unary operation: %s", op))
-	}
-}
-
-// Perform an assignment: can assign to var, array[key], or $field
-func (p *interp) assign(left ast.Expr, right value) error {
-	switch left := left.(type) {
-	case *ast.VarExpr:
-		return p.setVar(left.Scope, left.Index, right)
-	case *ast.IndexExpr:
-		index, err := p.evalIndex(left.Index)
-		if err != nil {
-			return err
-		}
-		p.setArrayValue(left.Array.Scope, left.Array.Index, index, right)
-		return nil
-	case *ast.FieldExpr:
-		index, err := p.eval(left.Index)
-		if err != nil {
-			return err
-		}
-		return p.setField(int(index.num()), p.toString(right))
-	}
-	// Shouldn't happen
-	panic(fmt.Sprintf("unexpected lvalue type: %T", left))
-}
-
-// Evaluate an index expression to a string. Multi-valued indexes are
-// separated by SUBSEP.
-func (p *interp) evalIndex(indexExprs []ast.Expr) (string, error) {
-	// Optimize the common case of a 1-dimensional index
-	if len(indexExprs) == 1 {
-		v, err := p.eval(indexExprs[0])
-		if err != nil {
-			return "", err
-		}
-		return p.toString(v), nil
-	}
-
-	// Up to 3-dimensional indices won't require heap allocation
-	indices := make([]string, 0, 3)
-	for _, expr := range indexExprs {
-		v, err := p.eval(expr)
-		if err != nil {
-			return "", err
-		}
-		indices = append(indices, p.toString(v))
-	}
-	return strings.Join(indices, p.subscriptSep), nil
-}
diff --git a/interp/io.go b/interp/io.go
index 30228a1..07ebd52 100644
--- a/interp/io.go
+++ b/interp/io.go
@@ -9,6 +9,7 @@ import (
 	"io"
 	"io/ioutil"
 	"os"
+	"os/exec"
 	"regexp"
 	"strconv"
 	"strings"
@@ -49,16 +50,7 @@ func (wc *bufferedWriteCloser) Close() error {
 
 // Determine the output stream for given redirect token and
 // destination (file or pipe name)
-func (p *interp) getOutputStream(redirect Token, dest ast.Expr) (io.Writer, error) {
-	if redirect == ILLEGAL {
-		// Token "ILLEGAL" means send to standard output
-		return p.output, nil
-	}
-
-	destValue, err := p.eval(dest)
-	if err != nil {
-		return nil, err
-	}
+func (p *interp) getOutputStream(redirect Token, destValue value) (io.Writer, error) {
 	name := p.toString(destValue)
 	if _, ok := p.inputStreams[name]; ok {
 		return nil, newError("can't write to reader stream")
@@ -121,6 +113,15 @@ func (p *interp) getOutputStream(redirect Token, dest ast.Expr) (io.Writer, erro
 	}
 }
 
+// Executes code using configured system shell
+func (p *interp) execShell(code string) *exec.Cmd {
+	executable := p.shellCommand[0]
+	args := p.shellCommand[1:]
+	args = append(args, code)
+	cmd := exec.Command(executable, args...)
+	return cmd
+}
+
 // Get input Scanner to use for "getline" based on file name
 func (p *interp) getInputScannerFile(name string) (*bufio.Scanner, error) {
 	if _, ok := p.outputStreams[name]; ok {
@@ -403,7 +404,7 @@ func (p *interp) nextLine() (string, error) {
 				// not present
 				index := strconv.Itoa(p.filenameIndex)
 				argvIndex := p.program.Arrays["ARGV"]
-				argvArray := p.arrays[p.getArrayIndex(ast.ScopeGlobal, argvIndex)]
+				argvArray := p.array(ast.ScopeGlobal, argvIndex)
 				filename := p.toString(argvArray[index])
 				p.filenameIndex++
 
diff --git a/interp/value.go b/interp/value.go
index ef5a422..04aa6f9 100644
--- a/interp/value.go
+++ b/interp/value.go
@@ -53,6 +53,20 @@ func boolean(b bool) value {
 	return num(0)
 }
 
+// String returns a string representation of v for debugging.
+func (v value) String() string {
+	switch v.typ {
+	case typeStr:
+		return fmt.Sprintf("str(%q)", v.s)
+	case typeNum:
+		return fmt.Sprintf("num(%s)", v.str("%.6g"))
+	case typeNumStr:
+		return fmt.Sprintf("numStr(%q)", v.s)
+	default:
+		return "null()"
+	}
+}
+
 // Return true if value is a "true string" (a string or a "numeric string"
 // from an input field that can't be converted to a number). If false,
 // also return the (possibly converted) number.
@@ -106,6 +120,9 @@ func (v value) str(floatFormat string) string {
 		case v.n == float64(int(v.n)):
 			return strconv.Itoa(int(v.n))
 		default:
+			if floatFormat == "%.6g" {
+				return strconv.FormatFloat(v.n, 'g', 6, 64)
+			}
 			return fmt.Sprintf(floatFormat, v.n)
 		}
 	}
diff --git a/interp/vm.go b/interp/vm.go
new file mode 100644
index 0000000..884ea14
--- /dev/null
+++ b/interp/vm.go
@@ -0,0 +1,1239 @@
+// Virtual machine: interpret GoAWK compiled opcodes
+
+package interp
+
+import (
+	"io"
+	"math"
+	"os"
+	"os/exec"
+	"strings"
+	"time"
+
+	"github.com/benhoyt/goawk/internal/ast"
+	"github.com/benhoyt/goawk/internal/compiler"
+	"github.com/benhoyt/goawk/lexer"
+)
+
+// Execute a block of virtual machine instructions.
+//
+// A big switch seems to be the best way of doing this for now. I also tried
+// an array of functions (https://github.com/benhoyt/goawk/commit/8e04b069b621ff9b9456de57a35ff2fe335cf201)
+// and it was ever so slightly faster, but the code was harder to work with
+// and it won't be improved when Go gets faster switches via jump tables
+// (https://go-review.googlesource.com/c/go/+/357330/).
+//
+// Additionally, I've made this version faster since the above test by
+// reducing the number of opcodes (replacing a couple dozen Call* opcodes with
+// a single CallBuiltin -- that probably pushed it below a switch binary tree
+// branch threshold).
+func (p *interp) execute(code []compiler.Opcode) error {
+	for i := 0; i < len(code); {
+		op := code[i]
+		i++
+
+		switch op {
+		case compiler.Num:
+			index := code[i]
+			i++
+			p.push(num(p.nums[index]))
+
+		case compiler.Str:
+			index := code[i]
+			i++
+			p.push(str(p.strs[index]))
+
+		case compiler.Dupe:
+			v := p.peekTop()
+			p.push(v)
+
+		case compiler.Drop:
+			p.pop()
+
+		case compiler.Swap:
+			l, r := p.peekTwo()
+			p.replaceTwo(r, l)
+
+		case compiler.Field:
+			index := p.peekTop()
+			v, err := p.getField(int(index.num()))
+			if err != nil {
+				return err
+			}
+			p.replaceTop(v)
+
+		case compiler.FieldInt:
+			index := code[i]
+			i++
+			v, err := p.getField(int(index))
+			if err != nil {
+				return err
+			}
+			p.push(v)
+
+		case compiler.Global:
+			index := code[i]
+			i++
+			p.push(p.globals[index])
+
+		case compiler.Local:
+			index := code[i]
+			i++
+			p.push(p.frame[index])
+
+		case compiler.Special:
+			index := code[i]
+			i++
+			p.push(p.getSpecial(int(index)))
+
+		case compiler.ArrayGlobal:
+			arrayIndex := code[i]
+			i++
+			array := p.arrays[arrayIndex]
+			index := p.toString(p.peekTop())
+			v := arrayGet(array, index)
+			p.replaceTop(v)
+
+		case compiler.ArrayLocal:
+			arrayIndex := code[i]
+			i++
+			array := p.localArray(int(arrayIndex))
+			index := p.toString(p.peekTop())
+			v := arrayGet(array, index)
+			p.replaceTop(v)
+
+		case compiler.InGlobal:
+			arrayIndex := code[i]
+			i++
+			array := p.arrays[arrayIndex]
+			index := p.toString(p.peekTop())
+			_, ok := array[index]
+			p.replaceTop(boolean(ok))
+
+		case compiler.InLocal:
+			arrayIndex := code[i]
+			i++
+			array := p.localArray(int(arrayIndex))
+			index := p.toString(p.peekTop())
+			_, ok := array[index]
+			p.replaceTop(boolean(ok))
+
+		case compiler.AssignField:
+			right, index := p.popTwo()
+			err := p.setField(int(index.num()), p.toString(right))
+			if err != nil {
+				return err
+			}
+
+		case compiler.AssignGlobal:
+			index := code[i]
+			i++
+			p.globals[index] = p.pop()
+
+		case compiler.AssignLocal:
+			index := code[i]
+			i++
+			p.frame[index] = p.pop()
+
+		case compiler.AssignSpecial:
+			index := code[i]
+			i++
+			err := p.setSpecial(int(index), p.pop())
+			if err != nil {
+				return err
+			}
+
+		case compiler.AssignArrayGlobal:
+			arrayIndex := code[i]
+			i++
+			array := p.arrays[arrayIndex]
+			v, index := p.popTwo()
+			array[p.toString(index)] = v
+
+		case compiler.AssignArrayLocal:
+			arrayIndex := code[i]
+			i++
+			array := p.localArray(int(arrayIndex))
+			v, index := p.popTwo()
+			array[p.toString(index)] = v
+
+		case compiler.Delete:
+			arrayScope := code[i]
+			arrayIndex := code[i+1]
+			i += 2
+			array := p.array(ast.VarScope(arrayScope), int(arrayIndex))
+			index := p.toString(p.pop())
+			delete(array, index)
+
+		case compiler.DeleteAll:
+			arrayScope := code[i]
+			arrayIndex := code[i+1]
+			i += 2
+			array := p.array(ast.VarScope(arrayScope), int(arrayIndex))
+			for k := range array {
+				delete(array, k)
+			}
+
+		case compiler.IncrField:
+			amount := code[i]
+			i++
+			index := int(p.pop().num())
+			v, err := p.getField(index)
+			if err != nil {
+				return err
+			}
+			err = p.setField(index, p.toString(num(v.num()+float64(amount))))
+			if err != nil {
+				return err
+			}
+
+		case compiler.IncrGlobal:
+			amount := code[i]
+			index := code[i+1]
+			i += 2
+			p.globals[index] = num(p.globals[index].num() + float64(amount))
+
+		case compiler.IncrLocal:
+			amount := code[i]
+			index := code[i+1]
+			i += 2
+			p.frame[index] = num(p.frame[index].num() + float64(amount))
+
+		case compiler.IncrSpecial:
+			amount := code[i]
+			index := int(code[i+1])
+			i += 2
+			v := p.getSpecial(index)
+			err := p.setSpecial(index, num(v.num()+float64(amount)))
+			if err != nil {
+				return err
+			}
+
+		case compiler.IncrArrayGlobal:
+			amount := code[i]
+			arrayIndex := code[i+1]
+			i += 2
+			array := p.arrays[arrayIndex]
+			index := p.toString(p.pop())
+			array[index] = num(array[index].num() + float64(amount))
+
+		case compiler.IncrArrayLocal:
+			amount := code[i]
+			arrayIndex := code[i+1]
+			i += 2
+			array := p.localArray(int(arrayIndex))
+			index := p.toString(p.pop())
+			array[index] = num(array[index].num() + float64(amount))
+
+		case compiler.AugAssignField:
+			operation := compiler.AugOp(code[i])
+			i++
+			right, indexVal := p.popTwo()
+			index := int(indexVal.num())
+			field, err := p.getField(index)
+			if err != nil {
+				return err
+			}
+			v, err := p.augAssignOp(operation, field, right)
+			if err != nil {
+				return err
+			}
+			err = p.setField(index, p.toString(v))
+			if err != nil {
+				return err
+			}
+
+		case compiler.AugAssignGlobal:
+			operation := compiler.AugOp(code[i])
+			index := code[i+1]
+			i += 2
+			v, err := p.augAssignOp(operation, p.globals[index], p.pop())
+			if err != nil {
+				return err
+			}
+			p.globals[index] = v
+
+		case compiler.AugAssignLocal:
+			operation := compiler.AugOp(code[i])
+			index := code[i+1]
+			i += 2
+			v, err := p.augAssignOp(operation, p.frame[index], p.pop())
+			if err != nil {
+				return err
+			}
+			p.frame[index] = v
+
+		case compiler.AugAssignSpecial:
+			operation := compiler.AugOp(code[i])
+			index := int(code[i+1])
+			i += 2
+			v, err := p.augAssignOp(operation, p.getSpecial(index), p.pop())
+			if err != nil {
+				return err
+			}
+			err = p.setSpecial(index, v)
+			if err != nil {
+				return err
+			}
+
+		case compiler.AugAssignArrayGlobal:
+			operation := compiler.AugOp(code[i])
+			arrayIndex := code[i+1]
+			i += 2
+			array := p.arrays[arrayIndex]
+			index := p.toString(p.pop())
+			v, err := p.augAssignOp(operation, array[index], p.pop())
+			if err != nil {
+				return err
+			}
+			array[index] = v
+
+		case compiler.AugAssignArrayLocal:
+			operation := compiler.AugOp(code[i])
+			arrayIndex := code[i+1]
+			i += 2
+			array := p.localArray(int(arrayIndex))
+			right, indexVal := p.popTwo()
+			index := p.toString(indexVal)
+			v, err := p.augAssignOp(operation, array[index], right)
+			if err != nil {
+				return err
+			}
+			array[index] = v
+
+		case compiler.Regex:
+			// Stand-alone /regex/ is equivalent to: $0 ~ /regex/
+			index := code[i]
+			i++
+			re := p.regexes[index]
+			p.push(boolean(re.MatchString(p.line)))
+
+		case compiler.MultiIndex:
+			numValues := int(code[i])
+			i++
+			values := p.popSlice(numValues)
+			indices := make([]string, 0, 3) // up to 3-dimensional indices won't require heap allocation
+			for _, v := range values {
+				indices = append(indices, p.toString(v))
+			}
+			p.push(str(strings.Join(indices, p.subscriptSep)))
+
+		case compiler.Add:
+			l, r := p.peekPop()
+			p.replaceTop(num(l.num() + r.num()))
+
+		case compiler.Subtract:
+			l, r := p.peekPop()
+			p.replaceTop(num(l.num() - r.num()))
+
+		case compiler.Multiply:
+			l, r := p.peekPop()
+			p.replaceTop(num(l.num() * r.num()))
+
+		case compiler.Divide:
+			l, r := p.peekPop()
+			rf := r.num()
+			if rf == 0.0 {
+				return newError("division by zero")
+			}
+			p.replaceTop(num(l.num() / rf))
+
+		case compiler.Power:
+			l, r := p.peekPop()
+			p.replaceTop(num(math.Pow(l.num(), r.num())))
+
+		case compiler.Modulo:
+			l, r := p.peekPop()
+			rf := r.num()
+			if rf == 0.0 {
+				return newError("division by zero in mod")
+			}
+			p.replaceTop(num(math.Mod(l.num(), rf)))
+
+		case compiler.Equals:
+			l, r := p.peekPop()
+			ln, lIsStr := l.isTrueStr()
+			rn, rIsStr := r.isTrueStr()
+			if lIsStr || rIsStr {
+				p.replaceTop(boolean(p.toString(l) == p.toString(r)))
+			} else {
+				p.replaceTop(boolean(ln == rn))
+			}
+
+		case compiler.NotEquals:
+			l, r := p.peekPop()
+			ln, lIsStr := l.isTrueStr()
+			rn, rIsStr := r.isTrueStr()
+			if lIsStr || rIsStr {
+				p.replaceTop(boolean(p.toString(l) != p.toString(r)))
+			} else {
+				p.replaceTop(boolean(ln != rn))
+			}
+
+		case compiler.Less:
+			l, r := p.peekPop()
+			ln, lIsStr := l.isTrueStr()
+			rn, rIsStr := r.isTrueStr()
+			if lIsStr || rIsStr {
+				p.replaceTop(boolean(p.toString(l) < p.toString(r)))
+			} else {
+				p.replaceTop(boolean(ln < rn))
+			}
+
+		case compiler.Greater:
+			l, r := p.peekPop()
+			ln, lIsStr := l.isTrueStr()
+			rn, rIsStr := r.isTrueStr()
+			if lIsStr || rIsStr {
+				p.replaceTop(boolean(p.toString(l) > p.toString(r)))
+			} else {
+				p.replaceTop(boolean(ln > rn))
+			}
+
+		case compiler.LessOrEqual:
+			l, r := p.peekPop()
+			ln, lIsStr := l.isTrueStr()
+			rn, rIsStr := r.isTrueStr()
+			if lIsStr || rIsStr {
+				p.replaceTop(boolean(p.toString(l) <= p.toString(r)))
+			} else {
+				p.replaceTop(boolean(ln <= rn))
+			}
+
+		case compiler.GreaterOrEqual:
+			l, r := p.peekPop()
+			ln, lIsStr := l.isTrueStr()
+			rn, rIsStr := r.isTrueStr()
+			if lIsStr || rIsStr {
+				p.replaceTop(boolean(p.toString(l) >= p.toString(r)))
+			} else {
+				p.replaceTop(boolean(ln >= rn))
+			}
+
+		case compiler.Concat:
+			l, r := p.peekPop()
+			p.replaceTop(str(p.toString(l) + p.toString(r)))
+
+		case compiler.Match:
+			l, r := p.peekPop()
+			re, err := p.compileRegex(p.toString(r))
+			if err != nil {
+				return err
+			}
+			matched := re.MatchString(p.toString(l))
+			p.replaceTop(boolean(matched))
+
+		case compiler.NotMatch:
+			l, r := p.peekPop()
+			re, err := p.compileRegex(p.toString(r))
+			if err != nil {
+				return err
+			}
+			matched := re.MatchString(p.toString(l))
+			p.replaceTop(boolean(!matched))
+
+		case compiler.Not:
+			p.replaceTop(boolean(!p.peekTop().boolean()))
+
+		case compiler.UnaryMinus:
+			p.replaceTop(num(-p.peekTop().num()))
+
+		case compiler.UnaryPlus:
+			p.replaceTop(num(p.peekTop().num()))
+
+		case compiler.Boolean:
+			p.replaceTop(boolean(p.peekTop().boolean()))
+
+		case compiler.Jump:
+			offset := code[i]
+			i += 1 + int(offset)
+
+		case compiler.JumpFalse:
+			offset := code[i]
+			i++
+			v := p.pop()
+			if !v.boolean() {
+				i += int(offset)
+			}
+
+		case compiler.JumpTrue:
+			offset := code[i]
+			i++
+			v := p.pop()
+			if v.boolean() {
+				i += int(offset)
+			}
+
+		case compiler.JumpEquals:
+			offset := code[i]
+			i++
+			l, r := p.popTwo()
+			ln, lIsStr := l.isTrueStr()
+			rn, rIsStr := r.isTrueStr()
+			var b bool
+			if lIsStr || rIsStr {
+				b = p.toString(l) == p.toString(r)
+			} else {
+				b = ln == rn
+			}
+			if b {
+				i += int(offset)
+			}
+
+		case compiler.JumpNotEquals:
+			offset := code[i]
+			i++
+			l, r := p.popTwo()
+			ln, lIsStr := l.isTrueStr()
+			rn, rIsStr := r.isTrueStr()
+			var b bool
+			if lIsStr || rIsStr {
+				b = p.toString(l) != p.toString(r)
+			} else {
+				b = ln != rn
+			}
+			if b {
+				i += int(offset)
+			}
+
+		case compiler.JumpLess:
+			offset := code[i]
+			i++
+			l, r := p.popTwo()
+			ln, lIsStr := l.isTrueStr()
+			rn, rIsStr := r.isTrueStr()
+			var b bool
+			if lIsStr || rIsStr {
+				b = p.toString(l) < p.toString(r)
+			} else {
+				b = ln < rn
+			}
+			if b {
+				i += int(offset)
+			}
+
+		case compiler.JumpGreater:
+			offset := code[i]
+			i++
+			l, r := p.popTwo()
+			ln, lIsStr := l.isTrueStr()
+			rn, rIsStr := r.isTrueStr()
+			var b bool
+			if lIsStr || rIsStr {
+				b = p.toString(l) > p.toString(r)
+			} else {
+				b = ln > rn
+			}
+			if b {
+				i += int(offset)
+			}
+
+		case compiler.JumpLessOrEqual:
+			offset := code[i]
+			i++
+			l, r := p.popTwo()
+			ln, lIsStr := l.isTrueStr()
+			rn, rIsStr := r.isTrueStr()
+			var b bool
+			if lIsStr || rIsStr {
+				b = p.toString(l) <= p.toString(r)
+			} else {
+				b = ln <= rn
+			}
+			if b {
+				i += int(offset)
+			}
+
+		case compiler.JumpGreaterOrEqual:
+			offset := code[i]
+			i++
+			l, r := p.popTwo()
+			ln, lIsStr := l.isTrueStr()
+			rn, rIsStr := r.isTrueStr()
+			var b bool
+			if lIsStr || rIsStr {
+				b = p.toString(l) >= p.toString(r)
+			} else {
+				b = ln >= rn
+			}
+			if b {
+				i += int(offset)
+			}
+
+		case compiler.Next:
+			return errNext
+
+		case compiler.Exit:
+			p.exitStatus = int(p.pop().num())
+			// Return special errExit value "caught" by top-level executor
+			return errExit
+
+		case compiler.ForIn:
+			varScope := code[i]
+			varIndex := code[i+1]
+			arrayScope := code[i+2]
+			arrayIndex := code[i+3]
+			offset := code[i+4]
+			i += 5
+			array := p.array(ast.VarScope(arrayScope), int(arrayIndex))
+			loopCode := code[i : i+int(offset)]
+			for index := range array {
+				switch ast.VarScope(varScope) {
+				case ast.ScopeGlobal:
+					p.globals[varIndex] = str(index)
+				case ast.ScopeLocal:
+					p.frame[varIndex] = str(index)
+				default: // ScopeSpecial
+					err := p.setSpecial(int(varIndex), str(index))
+					if err != nil {
+						return err
+					}
+				}
+				err := p.execute(loopCode)
+				if err == errBreak {
+					break
+				}
+				if err != nil {
+					return err
+				}
+			}
+			i += int(offset)
+
+		case compiler.BreakForIn:
+			return errBreak
+
+		case compiler.CallBuiltin:
+			builtinOp := compiler.BuiltinOp(code[i])
+			i++
+			err := p.callBuiltin(builtinOp)
+			if err != nil {
+				return err
+			}
+
+		case compiler.CallSplit:
+			arrayScope := code[i]
+			arrayIndex := code[i+1]
+			i += 2
+			s := p.toString(p.peekTop())
+			n, err := p.split(s, ast.VarScope(arrayScope), int(arrayIndex), p.fieldSep)
+			if err != nil {
+				return err
+			}
+			p.replaceTop(num(float64(n)))
+
+		case compiler.CallSplitSep:
+			arrayScope := code[i]
+			arrayIndex := code[i+1]
+			i += 2
+			s, fieldSep := p.peekPop()
+			n, err := p.split(p.toString(s), ast.VarScope(arrayScope), int(arrayIndex), p.toString(fieldSep))
+			if err != nil {
+				return err
+			}
+			p.replaceTop(num(float64(n)))
+
+		case compiler.CallSprintf:
+			numArgs := code[i]
+			i++
+			args := p.popSlice(int(numArgs))
+			s, err := p.sprintf(p.toString(args[0]), args[1:])
+			if err != nil {
+				return err
+			}
+			p.push(str(s))
+
+		case compiler.CallUser:
+			funcIndex := code[i]
+			numArrayArgs := int(code[i+1])
+			i += 2
+
+			f := p.program.Compiled.Functions[funcIndex]
+			if p.callDepth >= maxCallDepth {
+				return newError("calling %q exceeded maximum call depth of %d", f.Name, maxCallDepth)
+			}
+
+			// Set up frame for scalar arguments
+			oldFrame := p.frame
+			p.frame = p.peekSlice(f.NumScalars)
+
+			// Handle array arguments
+			var arrays []int
+			for j := 0; j < numArrayArgs; j++ {
+				arrayScope := ast.VarScope(code[i])
+				arrayIndex := int(code[i+1])
+				i += 2
+				arrays = append(arrays, p.arrayIndex(arrayScope, arrayIndex))
+			}
+			oldArraysLen := len(p.arrays)
+			for j := numArrayArgs; j < f.NumArrays; j++ {
+				arrays = append(arrays, len(p.arrays))
+				p.arrays = append(p.arrays, make(map[string]value))
+			}
+			p.localArrays = append(p.localArrays, arrays)
+
+			// Execute the function!
+			p.callDepth++
+			err := p.execute(f.Body)
+			p.callDepth--
+
+			// Pop the locals off the stack
+			p.popSlice(f.NumScalars)
+			p.frame = oldFrame
+			p.localArrays = p.localArrays[:len(p.localArrays)-1]
+			p.arrays = p.arrays[:oldArraysLen]
+
+			if r, ok := err.(returnValue); ok {
+				p.push(r.Value)
+			} else if err != nil {
+				return err
+			} else {
+				p.push(null())
+			}
+
+		case compiler.CallNative:
+			funcIndex := int(code[i])
+			numArgs := int(code[i+1])
+			i += 2
+
+			args := p.popSlice(numArgs)
+			r, err := p.callNative(funcIndex, args)
+			if err != nil {
+				return err
+			}
+			p.push(r)
+
+		case compiler.Return:
+			v := p.pop()
+			return returnValue{v}
+
+		case compiler.ReturnNull:
+			return returnValue{null()}
+
+		case compiler.Nulls:
+			numNulls := int(code[i])
+			i++
+			p.pushNulls(numNulls)
+
+		case compiler.Print:
+			numArgs := code[i]
+			redirect := lexer.Token(code[i+1])
+			i += 2
+
+			// Print OFS-separated args followed by ORS (usually newline)
+			var line string
+			if numArgs > 0 {
+				args := p.popSlice(int(numArgs))
+				strs := make([]string, len(args))
+				for i, a := range args {
+					strs[i] = a.str(p.outputFormat)
+				}
+				line = strings.Join(strs, p.outputFieldSep)
+			} else {
+				// "print" with no args is equivalent to "print $0"
+				line = p.line
+			}
+
+			output := p.output
+			if redirect != lexer.ILLEGAL {
+				var err error
+				dest := p.pop()
+				output, err = p.getOutputStream(redirect, dest)
+				if err != nil {
+					return err
+				}
+			}
+			err := p.printLine(output, line)
+			if err != nil {
+				return err
+			}
+
+		case compiler.Printf:
+			numArgs := code[i]
+			redirect := lexer.Token(code[i+1])
+			i += 2
+
+			args := p.popSlice(int(numArgs))
+			s, err := p.sprintf(p.toString(args[0]), args[1:])
+			if err != nil {
+				return err
+			}
+
+			output := p.output
+			if redirect != lexer.ILLEGAL {
+				dest := p.pop()
+				output, err = p.getOutputStream(redirect, dest)
+				if err != nil {
+					return err
+				}
+			}
+			err = writeOutput(output, s)
+			if err != nil {
+				return err
+			}
+
+		case compiler.Getline:
+			redirect := lexer.Token(code[i])
+			i++
+
+			ret, line, err := p.getline(redirect)
+			if err != nil {
+				return err
+			}
+			if ret == 1 {
+				p.setLine(line, false)
+			}
+			p.push(num(ret))
+
+		case compiler.GetlineField:
+			redirect := lexer.Token(code[i])
+			i++
+
+			ret, line, err := p.getline(redirect)
+			if err != nil {
+				return err
+			}
+			if ret == 1 {
+				err := p.setField(0, line)
+				if err != nil {
+					return err
+				}
+			}
+			p.push(num(ret))
+
+		case compiler.GetlineGlobal:
+			redirect := lexer.Token(code[i])
+			index := code[i+1]
+			i += 2
+
+			ret, line, err := p.getline(redirect)
+			if err != nil {
+				return err
+			}
+			if ret == 1 {
+				p.globals[index] = numStr(line)
+			}
+			p.push(num(ret))
+
+		case compiler.GetlineLocal:
+			redirect := lexer.Token(code[i])
+			index := code[i+1]
+			i += 2
+
+			ret, line, err := p.getline(redirect)
+			if err != nil {
+				return err
+			}
+			if ret == 1 {
+				p.frame[index] = numStr(line)
+			}
+			p.push(num(ret))
+
+		case compiler.GetlineSpecial:
+			redirect := lexer.Token(code[i])
+			index := code[i+1]
+			i += 2
+
+			ret, line, err := p.getline(redirect)
+			if err != nil {
+				return err
+			}
+			if ret == 1 {
+				err := p.setSpecial(int(index), numStr(line))
+				if err != nil {
+					return err
+				}
+			}
+			p.push(num(ret))
+
+		case compiler.GetlineArray:
+			redirect := lexer.Token(code[i])
+			arrayScope := code[i+1]
+			arrayIndex := code[i+2]
+			i += 3
+
+			ret, line, err := p.getline(redirect)
+			if err != nil {
+				return err
+			}
+			index := p.toString(p.peekTop())
+			if ret == 1 {
+				array := p.array(ast.VarScope(arrayScope), int(arrayIndex))
+				array[index] = numStr(line)
+			}
+			p.replaceTop(num(ret))
+		}
+	}
+	return nil
+}
+
+func (p *interp) callBuiltin(builtinOp compiler.BuiltinOp) error {
+	switch builtinOp {
+	case compiler.BuiltinAtan2:
+		y, x := p.peekPop()
+		p.replaceTop(num(math.Atan2(y.num(), x.num())))
+
+	case compiler.BuiltinClose:
+		name := p.toString(p.peekTop())
+		var c io.Closer = p.inputStreams[name]
+		if c != nil {
+			// Close input stream
+			delete(p.inputStreams, name)
+			err := c.Close()
+			if err != nil {
+				p.replaceTop(num(-1))
+			} else {
+				p.replaceTop(num(0))
+			}
+		} else {
+			c = p.outputStreams[name]
+			if c != nil {
+				// Close output stream
+				delete(p.outputStreams, name)
+				err := c.Close()
+				if err != nil {
+					p.replaceTop(num(-1))
+				} else {
+					p.replaceTop(num(0))
+				}
+			} else {
+				// Nothing to close
+				p.replaceTop(num(-1))
+			}
+		}
+
+	case compiler.BuiltinCos:
+		p.replaceTop(num(math.Cos(p.peekTop().num())))
+
+	case compiler.BuiltinExp:
+		p.replaceTop(num(math.Exp(p.peekTop().num())))
+
+	case compiler.BuiltinFflush:
+		name := p.toString(p.peekTop())
+		var ok bool
+		if name != "" {
+			// Flush a single, named output stream
+			ok = p.flushStream(name)
+		} else {
+			// fflush() or fflush("") flushes all output streams
+			ok = p.flushAll()
+		}
+		if !ok {
+			p.replaceTop(num(-1))
+		} else {
+			p.replaceTop(num(0))
+		}
+
+	case compiler.BuiltinFflushAll:
+		ok := p.flushAll()
+		if !ok {
+			p.push(num(-1))
+		} else {
+			p.push(num(0))
+		}
+
+	case compiler.BuiltinGsub:
+		regex, repl, in := p.peekPeekPop()
+		out, n, err := p.sub(p.toString(regex), p.toString(repl), p.toString(in), true)
+		if err != nil {
+			return err
+		}
+		p.replaceTwo(num(float64(n)), str(out))
+
+	case compiler.BuiltinIndex:
+		sValue, substr := p.peekPop()
+		s := p.toString(sValue)
+		index := strings.Index(s, p.toString(substr))
+		p.replaceTop(num(float64(index + 1)))
+
+	case compiler.BuiltinInt:
+		p.replaceTop(num(float64(int(p.peekTop().num()))))
+
+	case compiler.BuiltinLength:
+		p.push(num(float64(len(p.line))))
+
+	case compiler.BuiltinLengthArg:
+		s := p.toString(p.peekTop())
+		p.replaceTop(num(float64(len(s))))
+
+	case compiler.BuiltinLog:
+		p.replaceTop(num(math.Log(p.peekTop().num())))
+
+	case compiler.BuiltinMatch:
+		sValue, regex := p.peekPop()
+		s := p.toString(sValue)
+		re, err := p.compileRegex(p.toString(regex))
+		if err != nil {
+			return err
+		}
+		loc := re.FindStringIndex(s)
+		if loc == nil {
+			p.matchStart = 0
+			p.matchLength = -1
+			p.replaceTop(num(0))
+		} else {
+			p.matchStart = loc[0] + 1
+			p.matchLength = loc[1] - loc[0]
+			p.replaceTop(num(float64(p.matchStart)))
+		}
+
+	case compiler.BuiltinRand:
+		p.push(num(p.random.Float64()))
+
+	case compiler.BuiltinSin:
+		p.replaceTop(num(math.Sin(p.peekTop().num())))
+
+	case compiler.BuiltinSqrt:
+		p.replaceTop(num(math.Sqrt(p.peekTop().num())))
+
+	case compiler.BuiltinSrand:
+		prevSeed := p.randSeed
+		p.random.Seed(time.Now().UnixNano())
+		p.push(num(prevSeed))
+
+	case compiler.BuiltinSrandSeed:
+		prevSeed := p.randSeed
+		p.randSeed = p.peekTop().num()
+		p.random.Seed(int64(math.Float64bits(p.randSeed)))
+		p.replaceTop(num(prevSeed))
+
+	case compiler.BuiltinSub:
+		regex, repl, in := p.peekPeekPop()
+		out, n, err := p.sub(p.toString(regex), p.toString(repl), p.toString(in), false)
+		if err != nil {
+			return err
+		}
+		p.replaceTwo(num(float64(n)), str(out))
+
+	case compiler.BuiltinSubstr:
+		sValue, posValue := p.peekPop()
+		pos := int(posValue.num())
+		s := p.toString(sValue)
+		if pos > len(s) {
+			pos = len(s) + 1
+		}
+		if pos < 1 {
+			pos = 1
+		}
+		length := len(s) - pos + 1
+		p.replaceTop(str(s[pos-1 : pos-1+length]))
+
+	case compiler.BuiltinSubstrLength:
+		posValue, lengthValue := p.popTwo()
+		length := int(lengthValue.num())
+		pos := int(posValue.num())
+		s := p.toString(p.peekTop())
+		if pos > len(s) {
+			pos = len(s) + 1
+		}
+		if pos < 1 {
+			pos = 1
+		}
+		maxLength := len(s) - pos + 1
+		if length < 0 {
+			length = 0
+		}
+		if length > maxLength {
+			length = maxLength
+		}
+		p.replaceTop(str(s[pos-1 : pos-1+length]))
+
+	case compiler.BuiltinSystem:
+		if p.noExec {
+			return newError("can't call system() due to NoExec")
+		}
+		cmdline := p.toString(p.peekTop())
+		cmd := p.execShell(cmdline)
+		cmd.Stdout = p.output
+		cmd.Stderr = p.errorOutput
+		_ = p.flushAll() // ensure synchronization
+		err := cmd.Start()
+		var ret float64
+		if err != nil {
+			p.printErrorf("%s\n", err)
+			ret = -1
+		} else {
+			err = cmd.Wait()
+			if err != nil {
+				if exitErr, ok := err.(*exec.ExitError); ok {
+					ret = float64(exitErr.ProcessState.ExitCode())
+				} else {
+					p.printErrorf("unexpected error running command %q: %v\n", cmdline, err)
+					ret = -1
+				}
+			} else {
+				ret = 0
+			}
+		}
+		p.replaceTop(num(ret))
+
+	case compiler.BuiltinTolower:
+		p.replaceTop(str(strings.ToLower(p.toString(p.peekTop()))))
+
+	case compiler.BuiltinToupper:
+		p.replaceTop(str(strings.ToUpper(p.toString(p.peekTop()))))
+	}
+
+	return nil
+}
+
+// Fetch the value at the given index from array. This handles the strange
+// POSIX behavior of creating a null entry for non-existent array elements.
+// Per the POSIX spec, "Any other reference to a nonexistent array element
+// [apart from "in" expressions] shall automatically create it."
+func arrayGet(array map[string]value, index string) value {
+	v, ok := array[index]
+	if !ok {
+		array[index] = v
+	}
+	return v
+}
+
+// Stack operations follow. These should be inlined. Instead of just push and
+// pop, for efficiency we have custom operations for when we're replacing the
+// top of stack without changing the stack pointer. Primarily this avoids the
+// check for append in push.
+func (p *interp) push(v value) {
+	sp := p.sp
+	if sp >= len(p.stack) {
+		p.stack = append(p.stack, null())
+	}
+	p.stack[sp] = v
+	sp++
+	p.sp = sp
+}
+
+func (p *interp) pushNulls(num int) {
+	sp := p.sp
+	for p.sp+num-1 >= len(p.stack) {
+		p.stack = append(p.stack, null())
+	}
+	for i := 0; i < num; i++ {
+		p.stack[sp] = null()
+		sp++
+	}
+	p.sp = sp
+}
+
+func (p *interp) pop() value {
+	p.sp--
+	return p.stack[p.sp]
+}
+
+func (p *interp) popTwo() (value, value) {
+	p.sp -= 2
+	return p.stack[p.sp], p.stack[p.sp+1]
+}
+
+func (p *interp) peekTop() value {
+	return p.stack[p.sp-1]
+}
+
+func (p *interp) peekTwo() (value, value) {
+	return p.stack[p.sp-2], p.stack[p.sp-1]
+}
+
+func (p *interp) peekPop() (value, value) {
+	p.sp--
+	return p.stack[p.sp-1], p.stack[p.sp]
+}
+
+func (p *interp) peekPeekPop() (value, value, value) {
+	p.sp--
+	return p.stack[p.sp-2], p.stack[p.sp-1], p.stack[p.sp]
+}
+
+func (p *interp) replaceTop(v value) {
+	p.stack[p.sp-1] = v
+}
+
+func (p *interp) replaceTwo(l, r value) {
+	p.stack[p.sp-2] = l
+	p.stack[p.sp-1] = r
+}
+
+func (p *interp) popSlice(n int) []value {
+	p.sp -= n
+	return p.stack[p.sp : p.sp+n]
+}
+
+func (p *interp) peekSlice(n int) []value {
+	return p.stack[p.sp-n:]
+}
+
+// Helper for getline operations. This performs the (possibly redirected) read
+// of a line, and returns the result. If the result is 1 (success in AWK), the
+// caller will set the target to the returned string.
+func (p *interp) getline(redirect lexer.Token) (float64, string, error) {
+	switch redirect {
+	case lexer.PIPE: // redirect from command
+		name := p.toString(p.pop())
+		scanner, err := p.getInputScannerPipe(name)
+		if err != nil {
+			return 0, "", err
+		}
+		if !scanner.Scan() {
+			if err := scanner.Err(); err != nil {
+				return -1, "", nil
+			}
+			return 0, "", nil
+		}
+		return 1, scanner.Text(), nil
+
+	case lexer.LESS: // redirect from file
+		name := p.toString(p.pop())
+		scanner, err := p.getInputScannerFile(name)
+		if err != nil {
+			if _, ok := err.(*os.PathError); ok {
+				// File not found is not a hard error, getline just returns -1.
+				// See: https://github.com/benhoyt/goawk/issues/41
+				return -1, "", nil
+			}
+			return 0, "", err
+		}
+		if !scanner.Scan() {
+			if err := scanner.Err(); err != nil {
+				return -1, "", nil
+			}
+			return 0, "", nil
+		}
+		return 1, scanner.Text(), nil
+
+	default: // no redirect
+		p.flushOutputAndError() // Flush output in case they've written a prompt
+		var err error
+		line, err := p.nextLine()
+		if err == io.EOF {
+			return 0, "", nil
+		}
+		if err != nil {
+			return -1, "", nil
+		}
+		return 1, line, nil
+	}
+}
+
+// Perform augmented assignment operation.
+func (p *interp) augAssignOp(op compiler.AugOp, l, r value) (value, error) {
+	switch op {
+	case compiler.AugOpAdd:
+		return num(l.num() + r.num()), nil
+	case compiler.AugOpSub:
+		return num(l.num() - r.num()), nil
+	case compiler.AugOpMul:
+		return num(l.num() * r.num()), nil
+	case compiler.AugOpDiv:
+		rf := r.num()
+		if rf == 0.0 {
+			return null(), newError("division by zero")
+		}
+		return num(l.num() / rf), nil
+	case compiler.AugOpPow:
+		return num(math.Pow(l.num(), r.num())), nil
+	default: // AugOpMod
+		rf := r.num()
+		if rf == 0.0 {
+			return null(), newError("division by zero in mod")
+		}
+		return num(math.Mod(l.num(), rf)), nil
+	}
+}
diff --git a/parser/parser.go b/parser/parser.go
index 63042e6..ca69771 100644
--- a/parser/parser.go
+++ b/parser/parser.go
@@ -13,6 +13,7 @@ import (
 	"strings"
 
 	"github.com/benhoyt/goawk/internal/ast"
+	"github.com/benhoyt/goawk/internal/compiler"
 	. "github.com/benhoyt/goawk/lexer"
 )
 
@@ -68,10 +69,16 @@ func ParseProgram(src []byte, config *ParserConfig) (prog *Program, err error) {
 	}
 	p.initResolve()
 	p.next() // initialize p.tok
-	return p.program(), nil
+
+	// Parse into abstract syntax tree
+	prog = p.program()
+
+	// Compile to virtual machine code
+	prog.Compiled, err = compiler.Compile(prog.toAST())
+	return prog, err
 }
 
-// Program is the abstract syntax tree for an entire AWK program.
+// Program is the parsed and compiled representation of an entire AWK program.
 type Program struct {
 	// These fields aren't intended to be used or modified directly,
 	// but are exported for the interpreter (Program itself needs to
@@ -83,25 +90,31 @@ type Program struct {
 	Functions []ast.Function
 	Scalars   map[string]int
 	Arrays    map[string]int
+	Compiled  *compiler.Program
 }
 
 // String returns an indented, pretty-printed version of the parsed
 // program.
 func (p *Program) String() string {
-	parts := []string{}
-	for _, ss := range p.Begin {
-		parts = append(parts, "BEGIN {\n"+ss.String()+"}")
-	}
-	for _, a := range p.Actions {
-		parts = append(parts, a.String())
-	}
-	for _, ss := range p.End {
-		parts = append(parts, "END {\n"+ss.String()+"}")
-	}
-	for _, function := range p.Functions {
-		parts = append(parts, function.String())
+	return p.toAST().String()
+}
+
+// Disassemble writes a human-readable form of the program's virtual machine
+// instructions to writer.
+func (p *Program) Disassemble(writer io.Writer) error {
+	return p.Compiled.Disassemble(writer)
+}
+
+// toAST converts the *Program to an *ast.Program.
+func (p *Program) toAST() *ast.Program {
+	return &ast.Program{
+		Begin:     p.Begin,
+		Actions:   p.Actions,
+		End:       p.End,
+		Functions: p.Functions,
+		Scalars:   p.Scalars,
+		Arrays:    p.Arrays,
 	}
-	return strings.Join(parts, "\n\n")
 }
 
 // Parser state


Test patch:
diff --git a/internal/ast/specialvars_test.go b/internal/ast/specialvars_test.go
new file mode 100644
index 0000000..f21eee0
--- /dev/null
+++ b/internal/ast/specialvars_test.go
@@ -0,0 +1,44 @@
+package ast
+
+import (
+	"testing"
+)
+
+func TestNameIndex(t *testing.T) {
+	tests := []struct {
+		name  string
+		index int
+	}{
+		{"ILLEGAL", V_ILLEGAL},
+		{"ARGC", V_ARGC},
+		{"CONVFMT", V_CONVFMT},
+		{"FILENAME", V_FILENAME},
+		{"FNR", V_FNR},
+		{"FS", V_FS},
+		{"NF", V_NF},
+		{"NR", V_NR},
+		{"OFMT", V_OFMT},
+		{"OFS", V_OFS},
+		{"ORS", V_ORS},
+		{"RLENGTH", V_RLENGTH},
+		{"RS", V_RS},
+		{"RSTART", V_RSTART},
+		{"RT", V_RT},
+		{"SUBSEP", V_SUBSEP},
+		{"<unknown special var 42>", 42},
+	}
+	for _, test := range tests {
+		t.Run(test.name, func(t *testing.T) {
+			name := SpecialVarName(test.index)
+			if name != test.name {
+				t.Errorf("got %q, want %q", name, test.name)
+			}
+			if test.index <= V_LAST {
+				index := SpecialVarIndex(test.name)
+				if index != test.index {
+					t.Errorf("got %d, want %d", index, test.index)
+				}
+			}
+		})
+	}
+}
diff --git a/internal/compiler/disassembler_test.go b/internal/compiler/disassembler_test.go
new file mode 100644
index 0000000..297224d
--- /dev/null
+++ b/internal/compiler/disassembler_test.go
@@ -0,0 +1,51 @@
+package compiler
+
+import (
+	"bytes"
+	"regexp"
+	"strings"
+	"testing"
+)
+
+func TestDisassembler(t *testing.T) {
+	// Note: this doesn't really test the disassembly, just that each opcode
+	// disassembly includes the opcode name, to help catch silly typos.
+	for op := Nop; op < EndOpcode; op++ {
+		t.Run(op.String(), func(t *testing.T) {
+			p := Program{
+				Begin: []Opcode{op, 0, 0, 0, 0, 0, 0, 0},
+				Functions: []Function{
+					{
+						Name:       "f",
+						Params:     []string{"a", "k"},
+						Arrays:     []bool{true, false},
+						NumScalars: 1,
+						NumArrays:  1,
+					},
+				},
+				Nums:            []float64{0},
+				Strs:            []string{""},
+				Regexes:         []*regexp.Regexp{regexp.MustCompile("")},
+				scalarNames:     []string{"s"},
+				arrayNames:      []string{"a"},
+				nativeFuncNames: []string{"n"},
+			}
+			var buf bytes.Buffer
+			err := p.Disassemble(&buf)
+			if err != nil {
+				t.Fatalf("error disassembling opcode %s: %v", op, err)
+			}
+			lines := strings.Split(buf.String(), "\n")
+			if strings.TrimSpace(lines[0]) != "// BEGIN" {
+				t.Fatalf("first line should be \"// BEGIN\", not %q", lines[0])
+			}
+			fields := strings.Fields(lines[1])
+			if fields[0] != "0000" {
+				t.Fatalf("address should be \"0000\", not %q", fields[0])
+			}
+			if fields[1] != op.String() {
+				t.Fatalf("opcode name should be %q, not %q", op.String(), fields[1])
+			}
+		})
+	}
+}
diff --git a/interp/interp_test.go b/interp/interp_test.go
index c888a6d..0dcb655 100644
--- a/interp/interp_test.go
+++ b/interp/interp_test.go
@@ -56,6 +56,7 @@ var interpTests = []interpTest{
 	{`$1==42`, "foo\n42\nbar", "42\n", "", ""},
 	{`$1=="42"`, "foo\n42\nbar", "42\n", "", ""},
 	{`/foo/`, "foo\nx\nfood\nxfooz\nbar", "foo\nfood\nxfooz\n", "", ""},
+	{`/foo/ { print NR }  /foo/`, "foo\nx\nfood\n", "1\nfoo\n3\nfood\n", "", ""},
 	{`NR==2, NR==4`, "1\n2\n3\n4\n5\n6\n", "2\n3\n4\n", "", ""},
 	{`
 NR==2, NR==4 { print $0 }
@@ -97,9 +98,13 @@ NR==3, NR==5 { print NR }
 	{`BEGIN { for (i=5; ; ) { printf "%d ", i; i++; if (i>8) break; } }`, "", "5 6 7 8 ", "", ""},
 	{`BEGIN { for (i=5; ; i++) { printf "%d ", i; if (i>8) break; } }`, "", "5 6 7 8 9 ", "", ""},
 	{`BEGIN { for (i=5; i<8; i++) { printf "%d ", i } }`, "", "5 6 7 ", "", ""},
+	{`BEGIN { for (i=3; i>0; i--) { printf "%d ", i } }`, "", "3 2 1 ", "", ""},
+	{`BEGIN { for (i=3; i>=0; i--) { printf "%d ", i } }`, "", "3 2 1 0 ", "", ""},
 	{`BEGIN { for (i=0; i<10; i++) { if (i < 5) continue; printf "%d ", i } }`, "", "5 6 7 8 9 ", "", ""},
 	{`BEGIN { a[1]=1; a[2]=1; for (k in a) { s++; break } print s }`, "", "1\n", "", ""},
 	{`BEGIN { a[1]=1; a[2]=1; a[3]=1; for (k in a) { if (k==2) continue; s++ } print s }`, "", "2\n", "", ""},
+	{`function alen(a, k, n) { n=0; for (k in a) n++; return n }  BEGIN { a[1]=1; a[2]=1; print alen(a) }`, "", "2\n", "", ""},
+	{`BEGIN { a["x"]=1; for (SUBSEP in a) print SUBSEP, a[SUBSEP] }`, "", "x 1\n", "", ""},
 	{`BEGIN { while (i<3) { i++; s++; break } print s }`, "", "1\n", "", ""},
 	{`BEGIN { while (i<3) { i++; if (i==2) continue; s++ } print s }`, "", "2\n", "", ""},
 	{`BEGIN { do { i++; s++; break } while (i<3); print s }`, "", "1\n", "", ""},
@@ -108,6 +113,9 @@ NR==3, NR==5 { print NR }
 	{`BEGIN { while (i < 5) { print i; i++ } }`, "", "\n1\n2\n3\n4\n", "", ""},
 	{`BEGIN { do { print i; i++ } while (i < 5) }`, "", "\n1\n2\n3\n4\n", "", ""},
 	{`BEGIN { for (i=0; i<10; i++); printf "x" }`, "", "x", "", ""},
+	{`BEGIN { s="x"; while (s=="x") { print s; s="y" } }`, "", "x\n", "", ""},
+	{`BEGIN { s="x"; while (s!="") { print s; s="" } }`, "", "x\n", "", ""},
+	{`BEGIN { s="x"; while (s) { print s; s="" } }`, "", "x\n", "", ""},
 	// regression tests for break and continue with nested loops
 	{`
 BEGIN {
@@ -144,6 +152,7 @@ BEGIN {
 	{`BEGIN { a[] }`, "", "", "parse error at 1:11: expected expression instead of ]", "syntax error"},
 	{`BEGIN { delete a[] }`, "", "", "parse error at 1:18: expected expression instead of ]", "syntax error"},
 	{`BEGIN { a["x"] = 3; a["y"] = 4; delete a; for (k in a) print k, a[k] }`, "", "", "", ""},
+	{`function f(a) { print "x" in a, "y" in a }  BEGIN { b["x"] = 3; f(b) }`, "", "1 0\n", "", ""},
 
 	// Unary expressions: ! + -
 	{`BEGIN { print !42, !1, !0, !!42, !!1, !!0 }`, "", "0 0 1 1 1 0\n", "", ""},
@@ -180,7 +189,7 @@ BEGIN {
 
 	// Short-circuit && and || operators
 	{`
-function t() { print "t"; return 1 }
+function t() { print "t"; return 2 }
 function f() { print "f"; return 0 }
 BEGIN {
 	print f() && f()
@@ -190,7 +199,7 @@ BEGIN {
 }
 `, "", "f\n0\nf\n0\nt\nf\n0\nt\nt\n1\n", "", ""},
 	{`
-function t() { print "t"; return 1 }
+function t() { print "t"; return 2 }
 function f() { print "f"; return 0 }
 BEGIN {
 	print f() || f()
@@ -199,6 +208,8 @@ BEGIN {
 	print t() || t()
 }
 `, "", "f\nf\n0\nf\nt\n1\nt\n1\nt\n1\n", "", ""},
+	{`BEGIN { print 0&&0, 0&&2, 2&&0, 2&&2 }`, "", "0 0 0 1\n", "", ""},
+	{`BEGIN { print 0||0, 0||2, 2||0, 2||2 }`, "", "0 1 1 1\n", "", ""},
 
 	// Other binary expressions: + - * ^ / % CONCAT ~ !~
 	{`BEGIN { print 1+2, 1+2+3, 1+-2, -1+2, "1"+"2", 3+.14 }`, "", "3 6 -1 1 3 3.14\n", "", ""},
@@ -211,6 +222,10 @@ BEGIN {
 	{`BEGIN { print "food"~/oo/, "food"~/[oO]+d/, "food"~"f", "food"~"F", "food"~0 }`, "", "1 1 1 0 0\n", "", ""},
 	{`BEGIN { print "food"!~/oo/, "food"!~/[oO]+d/, "food"!~"f", "food"!~"F", "food"!~0 }`, "", "0 0 0 1 1\n", "", ""},
 	{`BEGIN { print 1+2*3/4^5%6 7, (1+2)*3/4^5%6 "7" }`, "", "1.005867 0.008789067\n", "", ""},
+	{`BEGIN { print 1/0 }`, "", "", "division by zero", "division by zero"},
+	{`BEGIN { print 1%0 }`, "", "", "division by zero in mod", "division by zero"},
+	{`BEGIN { x /= 0 }`, "", "", "division by zero", "division by zero"},
+	{`BEGIN { x %= 0 }`, "", "", "division by zero in mod", "division by zero"},
 
 	// Number, string, and regex expressions
 	{`BEGIN { print 1, 1., .1, 1e0, -1, 1e }`, "", "1 1 0.1 1 -1 1\n", "", ""},
@@ -306,7 +321,7 @@ Record = record 2 and RT = [ BBBB ]
 Record = record 3 and RT = [
 ]
 `, "", ""},
-	{`BEGIN { RS = "\n|( *[[:upper:]]+ *)" } { print "Record =", $0,"and RT = [" RT "]" }`, // from https://www.gnu.org/software/gawk/manual/html_node/gawk-split-records.html
+	{`BEGIN { RS = "\n|( *[[:upper:]]+ *)" } { print "Record =", $0,"and RT = [" RT "]" }`,
 		"record 1 AAAA record 2 BBBB record 3",
 		`Record = record 1 and RT = [ AAAA ]
 Record = record 2 and RT = [ BBBB ]
@@ -397,6 +412,10 @@ BEGIN {
 	{`BEGIN { x=2; x^=5; print x; x^=0.5; print x }`, "", "32\n5.65685\n", "", ""},
 	{`{ $2+=10; print; $3/=2; print }`, "1 2 3", "1 12 3\n1 12 1.5\n", "", ""},
 	{`BEGIN { a[2] += 1; a["2"] *= 3; print a[2] }`, "", "3\n", "", ""},
+	{`function inc(x, n) { x += n; return x }  BEGIN { print inc(3, 2) }`, "", "5\n", "", ""},
+	{`function inca(a, k, n) { a[k] += n }  BEGIN { b["x"]=7; inca(b, "x", 2); print b["x"] }`, "", "9\n", "", ""},
+	{`BEGIN { NF += 3; print NF }`, "", "3\n", "", ""},
+	{`BEGIN { x=1; x += x+=3; print x }`, "", "8\n", "", ""},
 
 	// Incr/decr expressions
 	{`BEGIN { print x++; print x }`, "", "0\n1\n", "", ""},
@@ -408,6 +427,9 @@ BEGIN {
 	{`BEGIN { x[y++] += 3; print y }`, "", "1\n", "", ""},
 	{`BEGIN { $(y++)++; print y }`, "", "1\n", "", ""},
 	{`BEGIN { print "s" ++n; print "s" --n }`, "", "s1\ns0\n", "", ""},
+	{`function inc(x) { x++; return x }  BEGIN { print inc(3) }`, "", "4\n", "", ""},
+	{`function inca(a, k) { a[k]++ }  BEGIN { b["x"]=7; inca(b, "x"); print b["x"] }`, "", "8\n", "", ""},
+	{`BEGIN { NF++; print NF }`, "", "1\n", "", ""},
 
 	// Builtin functions
 	{`BEGIN { print sin(0), sin(0.5), sin(1), sin(-1) }`, "", "0 0.479426 0.841471 -0.841471\n", "", ""},
@@ -430,6 +452,8 @@ BEGIN {
 	{`BEGIN { print sprintf("%d") }`, "", "", "format error: got 0 args, expected 1", "not enough arg"},
 	{`BEGIN { print sprintf("%d", 12, 34) }`, "", "12\n", "", ""},
 	{`BEGIN { print sprintf("% 5d", 42) }`, "", "   42\n", "", ""},
+	{`BEGIN { print sprintf("%u", -1) }`, "", "18446744073709551615\n", "", ""},
+	{`BEGIN { print sprintf("%*s %.*s", 5, "abc", 5, "abcdefghi") }`, "", "  abc abcde\n", "", ""},
 	{`BEGIN { print substr("food", 1) }`, "", "food\n", "", ""},
 	{`BEGIN { print substr("food", 1, 2) }`, "", "fo\n", "", ""},
 	{`BEGIN { print substr("food", 1, 4) }`, "", "food\n", "", ""},
@@ -442,11 +466,14 @@ BEGIN {
 	{`BEGIN { print substr("food", -1, 8) }`, "", "food\n", "", ""},
 	{`BEGIN { print substr("food", 5, 8) }`, "", "\n", "", ""},
 	{`BEGIN { print substr("food", 2, -3), substr("fööd", 2, -3) }`, "", " \n", "", ""},
+	{`BEGIN { n = split("", a); for (i=1; i<=n; i++) print a[i] }`, "", "", "", ""},
+	{`BEGIN { n = split("", a, "."); for (i=1; i<=n; i++) print a[i] }`, "", "", "", ""},
 	{`BEGIN { n = split("ab c d ", a); for (i=1; i<=n; i++) print a[i] }`, "", "ab\nc\nd\n", "", ""},
 	{`BEGIN { n = split("ab,c,d,", a, ","); for (i=1; i<=n; i++) print a[i] }`, "", "ab\nc\nd\n\n", "", ""},
 	{`BEGIN { n = split("ab,c.d,", a, /[,.]/); for (i=1; i<=n; i++) print a[i] }`, "", "ab\nc\nd\n\n", "", ""},
 	{`BEGIN { n = split("1 2", a); print (n, a[1], a[2], a[1]==1, a[2]==2) }`, "", "2 1 2 1 1\n", "", ""},
 	{`BEGIN { x = "1.2.3"; print sub(/\./, ",", x); print x }`, "", "1\n1,2.3\n", "", ""},
+	{`BEGIN { x = "1.2.3"; print sub(/\./, ",\\", x); print x }`, "", "1\n1,\\2.3\n", "", ""},
 	{`{ print sub(/\./, ","); print $0 }`, "1.2.3", "1\n1,2.3\n", "", ""},
 	{`BEGIN { x = "1.2.3"; print gsub(/\./, ",", x); print x }`, "", "2\n1,2,3\n", "", ""},
 	{`{ print gsub(/\./, ","); print $0 }`, "1.2.3", "2\n1,2,3\n", "", ""},
@@ -462,6 +489,7 @@ BEGIN {
 	{`BEGIN { print toupper("Foo BaR") }`, "", "FOO BAR\n", "", ""},
 	{`
 BEGIN {
+    srand()
 	srand(1)
 	a = rand(); b = rand(); c = rand()
 	srand(1)
@@ -518,6 +546,7 @@ BEGIN {
 function f(loc) {
 	glob += 1
 	loc += 1
+	loc = loc * 2
 	print glob, loc
 }
 BEGIN {
@@ -528,14 +557,12 @@ BEGIN {
 	f(4)
 	print loc
 }
-`, "", "2 4\n42\n3 5\n42\n", "", ""},
+`, "", "2 8\n42\n3 10\n42\n", "", ""},
 	{`
-function set(a, x, v) {
-	a[x] = v
-}
-function get(a, x) {
-	return a[x]
-}
+function set(a, x, v) { a[x] = v }
+function get(a, x) { return a[x] }
+function get2(x, a) { return a[x] }
+function get3(x, a, b) { b[0]; return a[x] }
 BEGIN {
 	a["x"] = 1
 	set(b, "y", 2)
@@ -544,8 +571,10 @@ BEGIN {
 	for (k in b) print k, b[k]
 	print "---"
 	print get(a, "x"), get(b, "y")
+	print get2("x", a), get2("y", b)
+	print get3("x", a), get2("y", b)
 }
-`, "", "x 1\n---\ny 2\n---\n1 2\n", "", ""},
+`, "", "x 1\n---\ny 2\n---\n1 2\n1 2\n1 2\n", "", ""},
 	{`
 function fib(n) {
 	return n < 3 ? 1 : fib(n-2) + fib(n-1)
@@ -607,6 +636,7 @@ function f2(x, y) { return x[y] }
 BEGIN { a[1]=2; f1(a); print f2(a, 1) }
 `, "", "2\n", "", ""},
 	{`BEGIN { arr[0]; f(arr) } function f(a) { print "x" }`, "", "x\n", "", ""},
+	{`function add(a, b) { return a+b }  BEGIN { print add(1, 2), add(1), add() }`, "", "3 1 0\n", "", ""},
 
 	// Type checking / resolver tests
 	{`BEGIN { a[x]; a=42 }`, "", "", `parse error at 1:15: can't use array "a" as scalar`, "array"},
@@ -633,6 +663,9 @@ BEGIN { foo(5); bar(10) }
 	{`function f(x) { print x, x(); }  BEGIN { f() }`, "", "", `parse error at 1:27: can't call local variable "x" as function`, "function"},
 
 	// Redirected I/O
+	{`BEGIN { getline x; print x }`, "foo", "foo\n", "", ""},
+	{`function f(x) { getline x; print x }  BEGIN { f(); print x }`, "foo", "foo\n\n", "", ""},
+	{`BEGIN { getline SUBSEP; print SUBSEP }`, "foo", "foo\n", "", ""},
 	{`BEGIN { getline a[1]; print a[1] }`, "foo", "foo\n", "", ""},
 	{`BEGIN { getline $1; print $1 }`, "foo", "foo\n", "", ""},
 	{`BEGIN { "echo foo" | getline a[1]; print a[1] }`, "", "foo\n", "", ""},
@@ -709,36 +742,38 @@ func TestInterp(t *testing.T) {
 		}
 
 		// Run it through external awk program first
-		t.Run("awk_"+testName, func(t *testing.T) {
-			if awkExe != "" && strings.Contains(test.src, "!"+awkExe) {
-				t.Skipf("skipping under %s", awkExe)
-			}
-			if strings.Contains(test.src, "!"+runtime.GOOS+"-"+awkExe) {
-				t.Skipf("skipping on %s under %s", runtime.GOOS, awkExe)
-			}
-			cmd := exec.Command(awkExe, test.src, "-")
-			if test.in != "" {
-				cmd.Stdin = strings.NewReader(test.in)
-			}
-			out, err := cmd.CombinedOutput()
-			if err != nil {
-				if test.awkErr != "" {
-					if strings.Contains(string(out), test.awkErr) {
-						return
+		if awkExe != "" {
+			t.Run("awk_"+testName, func(t *testing.T) {
+				if strings.Contains(test.src, "!"+awkExe) {
+					t.Skipf("skipping under %s", awkExe)
+				}
+				if strings.Contains(test.src, "!"+runtime.GOOS+"-"+awkExe) {
+					t.Skipf("skipping on %s under %s", runtime.GOOS, awkExe)
+				}
+				cmd := exec.Command(awkExe, test.src, "-")
+				if test.in != "" {
+					cmd.Stdin = strings.NewReader(test.in)
+				}
+				out, err := cmd.CombinedOutput()
+				if err != nil {
+					if test.awkErr != "" {
+						if strings.Contains(string(out), test.awkErr) {
+							return
+						}
+						t.Fatalf("expected error %q, got:\n%s", test.awkErr, out)
+					} else {
+						t.Fatalf("error running %s: %v:\n%s", awkExe, err, out)
 					}
-					t.Fatalf("expected error %q, got:\n%s", test.awkErr, out)
-				} else {
-					t.Fatalf("error running %s: %v:\n%s", awkExe, err, out)
 				}
-			}
-			if test.awkErr != "" {
-				t.Fatalf(`expected error %q, got ""`, test.awkErr)
-			}
-			normalized := normalizeNewlines(string(out))
-			if normalized != test.out {
-				t.Fatalf("expected %q, got %q", test.out, normalized)
-			}
-		})
+				if test.awkErr != "" {
+					t.Fatalf(`expected error %q, got ""`, test.awkErr)
+				}
+				normalized := normalizeNewlines(string(out))
+				if normalized != test.out {
+					t.Fatalf("expected %q, got %q", test.out, normalized)
+				}
+			})
+		}
 
 		// Then test it in GoAWK
 		t.Run(testName, func(t *testing.T) {
@@ -785,6 +820,13 @@ func testGoAWK(
 		}
 		t.Fatal(err)
 	}
+
+	// Test that disassembler at least doesn't panic or return an error.
+	err = prog.Disassemble(ioutil.Discard)
+	if err != nil {
+		t.Fatalf("disassembler returned an error: %v", err)
+	}
+
 	outBuf := &concurrentBuffer{}
 	config := &interp.Config{
 		Stdin:  strings.NewReader(in),
@@ -796,7 +838,7 @@ func testGoAWK(
 	if configure != nil {
 		configure(config)
 	}
-	_, err = interp.ExecProgram(prog, config)
+	status, err := interp.ExecProgram(prog, config)
 	if err != nil {
 		if errStr != "" {
 			if err.Error() == errStr {
@@ -813,6 +855,9 @@ func testGoAWK(
 	if normalized != out {
 		t.Fatalf("expected %q, got %q", out, normalized)
 	}
+	if status != 0 {
+		t.Fatalf("expected status 0, got %d", status)
+	}
 }
 
 func TestNative(t *testing.T) {
@@ -1030,6 +1075,10 @@ BEGIN { x=4; y=5; print foo(x), bar(y) }
 			map[string]interface{}{
 				"add": func(a, b float64) float64 { return a + b },
 			}},
+		{`BEGIN { print add(1, add(2, 3)) }`, "", "6\n", "",
+			map[string]interface{}{
+				"add": func(a, b float32) float32 { return a + b },
+			}},
 		{`BEGIN { print foo(x) }`, "", "0\n", "",
 			map[string]interface{}{
 				"foo": func(i int) int { return i },
@@ -1239,6 +1288,45 @@ BEGIN {
 	})
 }
 
+func TestExit(t *testing.T) {
+	tests := []struct {
+		src    string
+		out    string
+		status int
+	}{
+		{`BEGIN { print "x"; exit; print "y" }  { print "a" }  END { print "z" }`, "x\nz\n", 0},
+		{`BEGIN { print "x"; exit 1+2; print "y" }  { print "a" }  END { print "z" }`, "x\nz\n", 3},
+		{`{ print "x"; exit; print "y" }  END { print "z" }`, "x\nz\n", 0},
+		{`{ print "x"; exit 1+2; print "y" }  END { print "z" }`, "x\nz\n", 3},
+		{`END { print "x"; exit; print "y" }`, "x\n", 0},
+		{`END { print "x"; exit 1+2; print "y" }`, "x\n", 3},
+	}
+	for _, test := range tests {
+		t.Run(test.src, func(t *testing.T) {
+			prog, err := parser.ParseProgram([]byte(test.src), nil)
+			if err != nil {
+				t.Fatalf("error parsing: %v", err)
+			}
+			outBuf := &bytes.Buffer{}
+			config := &interp.Config{
+				Stdin:  strings.NewReader("line\n"),
+				Output: outBuf,
+			}
+			status, err := interp.ExecProgram(prog, config)
+			if err != nil {
+				t.Fatalf("error interpreting: %v", err)
+			}
+			normalized := normalizeNewlines(outBuf.String())
+			if normalized != test.out {
+				t.Fatalf("expected %q, got %q", test.out, normalized)
+			}
+			if status != test.status {
+				t.Fatalf("expected status %d, got %d", test.status, status)
+			}
+		})
+	}
+}
+
 func benchmarkProgram(b *testing.B, funcs map[string]interface{},
 	input, expected, srcFormat string, args ...interface{},
 ) {