Optimizing GoAWK with a bytecode compiler and virtual machine
benhoyt.com
benhoyt.com
To back up my simplicity claim, consider rp [2] -- like 60 non-comment/import/signature lines of code for the generator. Generated programs are even smaller. But, you can deploy gcc or clang or whatever against them and make fast libraries in the host language.
Why, if you are willing to write those little generation command options in C99 then you can compile the harness with tcc [3] in about 1 millisecond which is faster than most interpreter start-up times - byte code or otherwise - and can link against gcc -O3 (or whatever) helper libraries.
Anyway, I only write this because in my experience few people realize how much development cost they buy into when then insist on a full featured prog.lang, not to criticize Ben's work. You also make users learn quirks of a new language instead of the quirks of a "harness" which may be fewer|easier. (EDIT: Awk is fairly well established, of course.)
[1] https://forum.nim-lang.org/
[2] https://github.com/c-blake/cligen/blob/master/examples/rp.ni...
Unfortunately, yes. My hope was that it would be compact/small enough to be "quasi-self-documenting" to the likes of "compiler writer types". Probably not to ordinary users. (The extra asterisks are to get nice ANSI SGR escape highlighting and/or rST markup out of the auto-documentation system.) {EDIT2: Also, I would be happy to add some rp.README if you want to contribute one. You seem a great explainer. }
FWIW, I think of this kind of generation as part of the Go mentality more generally, but I am not a Go user/in that community. So, maybe that is speaking out of turn.
I also have a C version of this that I call `crp` I could provide if anyone wants (and yes, short for "crap"). C is a kind of higher ceremony language for such things { EDIT1: but even more established than awk... :-) }
import std/[strutils,os,hashes,sets],cligen/[osUt,mslice] #% exec* mdOpen split
from cligen/parseopt3 import optionNormalize
proc toDef(fields, delim, genF: string): string =
result.add "char const * const rpNmFields = \"" & fields & "\";\n"
let sep = initSep(delim)
let row = fields.toMSlice
var s: seq[MSlice]
var nms: HashSet[string]
sep.split(row, s) # No maxSplit - define every field; Could infer it from the
for j, f in s: #..highest referenced field with a `where` & `stmts` parse.
let nm = optionNormalize(genF % [ $f ]) # Prevent duplicate def errors..
if nm notin nms: #..and warn users about collision.
result.add "int const " & nm & " = " & $j & ";\n"
nms.incl nm
else:
stderr.write "crp: WARNING: ", nm, " collides with earlier field\n"
proc crp(prelude="", begin="", where="1", stmts:seq[string], epilog="",
fields="", genF="$1", comp="", run=true, args="", outp="/tmp/crpXXX",
input="/dev/stdin", delim=" \t", uncheck=false, maxSplit=0): int =
## Gen+Run *prelude*,*fields*,*begin*,*where*,*stmts*,*epilog* row processor
## against *input*. Defined within *where* & every *stmt* are:
## *s[idx]* & *row* => C strings, *i(idx)* => int64, *f(idx)* => double.
## *nf* & *nr* (like *AWK*); NOTE: *idx* is **0-origin**.
## A generated program is left at *outp*.c, easily copied for "utilitizing".
## If you know *AWK* & C, you can learn *crp* PRONTO. Examples (need data):
## **crp 'printf("%s %s\\n", s[1], s[0]);'** # Swap field order
## **crp -w'nr % 100==0' 'printf("%s\\n", row);'** # Prn each 100th row
## **crp -b'int t=0' t+=nf -e'printf("%d\\n", t)'** # Prn total field count
## **crp -b'int t=0' -w'i(0)>0' 't+=i(0)' -e'printf("%d\\n", t)'** # Total>0
## **crp 'float x=f(0)' 'printf("%g\\n", (1+x)/x)'** # cache field 0 parse
## **crp -d, -fa,b,c 'printf("%s %g\\n",s[a],f(b)+i(c))'** # named fields
## Add niceties (eg. `#include "mystuff.h"`) to *prelude* in ~/.config/crp.
let fields = if fields.len == 0: fields else: toDef(fields, delim, genF)
let check = if fields.len == 0: " " elif not uncheck: """
if (nr == 0) {
if (strcmp(row, rpNmFields) == 0) {
nr++; continue; // {fields} {!uncheck}
} else {
exit(2);
}
while ((rpNmRead = getline(&row, &rpNmAlloc, rpNmFile)) != -1) {
row[rpNmRead - 1] = '\0'; // chop newline
${6}s = rpNmSplit(s, &rpNmAlloc, row, "$3", $7, &nf); // {delim,maxSplit}
if ($8) { // {where} auto ()s?
""" % [prelude, fields, delim, indent(begin, 2), input, check, $maxSplit, where]
for i, stmt in stmts:
program.add " " & stmt & "; // {stmt" & $i & "}\n"
if stmts.len == 0:
program.add " /**/;\n"
program.add " }\n nr++;\n }\n"
program.add indent(epilog, 2)
program.add "; // {epilogue}\n}\n"
let mode = if run: "-run" else: ""
let args = if args.len > 0: args else: "-I$HOME/s -O"
let digs = count(outp, 'X')
let hsh = toHex(program.hash and ((1 shl 16*digs) - 1), digs)
let outp = if digs > 0: outp[0 ..< ^digs] & hsh else: outp
let comp = if comp.len > 0: comp else: "tcc $1 $2 -o$3 $4" % [
mode, args, outp, outp & ".c"]
let f = mkdirOpen(outp & ".c", fmWrite)
f.write program
f.close
execShellCmd(comp & (if run: " < " & input else: ""))
when isMainModule:
import cligen; include cligen/mergeCfgEnv
dispatch crp,help={"prelude" : "Nim code for prelude/imports section",
"begin" : "Nim code for begin/pre-loop section",
"where" : "Nim code for row inclusion",
"stmts" : "Nim stmts to run under `where`",
"epilog" : "Nim code for epilog/end loop section",
"fields" : "`delim`-sep field names (match row0)",
"genF" : "make field names from this fmt; eg c$1",
"comp" : "\"\" => tcc {if run: \"-run\"} {args}",
"run" : "Run at once using tcc -run .. < input",
"args" : "\"\" => -I$HOME/s -O",
"outp" : "output executable; .c NOT REMOVED",
"input" : "path to read as input",
"delim" : "inp delim chars for strtok",
"uncheck" : "do not check&skip header row vs fields",
"maxSplit": "max split; 0 => unbounded"}, cmdName="crp"Then one needs to go around explaining, that no, some one programming language could have been chosen and it was just a matter of convinience why C was picked up.
My own view is that awk was done more or less for these one-liner/simple purposes but by people like Aho for whom full featured languages are barely more thinking than my 60 lines (in either doing the parser or using). :-)
> This only gave me a 1-2% speed increase on GoAWK’s microbenchmarks (see results and code). In the end I decided I’d rather stick with the simpler switch code and find other ways to improve the speed. And when the Go compiler supports jump tables for switch, I’ll get a 10% improvement by doing nothing!
YMMV with computed goto. It was an important technique but modern processors with their crazy branch predictors can yield disappointing results today. It didn’t help my VM.
I would -not- write your own regex library. Doesn’t RE2 have a Go port? Or maybe something else? It’s a world of pain.
Given AWK’s usage as a string processor, I’d sell out hard for string processing. Maybe an opcode specifically for “hardcoded string op” with a secondary operand denoting which type of string op that you dispatch to through an array? Then just load that up with beefy functions and the VM is used to glue together the string operations.
Also, not sure how possible this is in Go, but if it allows for unions, packed structs, and bitfields, you can get some benefit from having 32 bit instructions where the opcodes are a byte and then have the other three bytes available for operands, and additionally allowing for some instructions to be multiword (so a jump could have a second word following that’s the target address, for a full 32 bits). Condensing your byte code without incurring other costs (bit access, compression) is key to improving your L1 cache rate. A cache line can then fit 16 instructions.
Preallocating as much memory as possible is helpful, too. Even if there are necessary conditionals for growing arrays and whatnot, they’re likely to be false and thus handled by branch prediction with no cost.
For folks not familiar, basically every major interpreter today compiles to bytecode and runs on a bytecode VM. People will squabble about the term "compiler" saying it only applies to generating binary files directly but I still think it's fun almost every major interpreter is a compiler in the general sense.
The only major interpreters that do tree walking AFAIK are shells like bash.
I ask because while some opcodes are obviously needed others as not so obvious and coming up with a balanced instruction set is somewhat difficult design problem.
I think if you write an interpreter in RPython, and annotate it with some directives, you can get a JIT compiler for free. But it wouldn't be "GoAWK" anymore!
Compiling straight to Go, and then compiling that the normal way, seems like it would do better, and be simpler. Assuming you really wanted to keep it all in Go.
I also did a project recently, creatively called AWKGo, that compiles (a subset of) AWK scripts to Go source code: https://benhoyt.com/writings/awkgo/