Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,13 @@ WORKDIR /app
COPY . /app

RUN lua5.1 tests/harness/metadata.lua \
&& timeout 30s lua5.1 tests/harness/numbers-to-expressions.lua \
&& timeout 30s lua5.1 tests/harness/vm-arity.lua \
&& timeout 60s lua5.1 tests/harness/numbers-to-expressions.lua \
&& timeout 60s lua5.1 tests/harness/cff-arity.lua \
&& timeout 60s lua5.1 tests/harness/cff-native.lua \
&& timeout 60s lua5.1 tests/harness/vm-arity.lua \
&& timeout 60s lua5.1 tests/harness/vm-prototypes.lua \
&& timeout 60s lua5.1 tests/harness/vm-fastpaths.lua \
&& timeout 60s lua5.1 tests/harness/vm-layout.lua \
&& python3 tests/harness/runner.py

ENTRYPOINT ["lua5.1", "docker-test-runner.lua"]
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
**Prometheus** is a Lua obfuscator written in pure Lua.

It applies a range of **AST-based transformations** to make source code significantly harder to read, analyze, and reverse engineer.
These include techniques such as **control-flow flattening**, **constant encryption**, and other Lua-specific obfuscation strategies.
These include **register VM virtualization**, **control-flow flattening**, **constant encryption**, and other Lua-specific obfuscation strategies.

The project was inspired by the excellent [javascript-obfuscator](https://github.com/javascript-obfuscator/javascript-obfuscator).

Expand Down Expand Up @@ -153,7 +153,7 @@ official Lua 5.1 suite on Lua 5.1, inside Docker:
./scripts/run-tests.sh -c config.lua # Use a custom config
./scripts/run-tests.sh -v # Verbose output
./scripts/run-tests.sh --suite upstream --baseline-only # Verify official originals
./scripts/run-tests.sh --case lua51/closure.lua --preset Vmify --seed 4 -n 1
./scripts/run-tests.sh --case lua51/closure.lua --preset Cff --seed 4 -n 1
```

---
Expand Down
42 changes: 42 additions & 0 deletions benchmarks/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# Preset benchmarks

Recorded v0.3 measurements: [v0.3-results.md](v0.3-results.md).
Updated Medium measurements: [v0.3-medium-native-cff.md](v0.3-medium-native-cff.md).
Latest VM optimization measurements: [v0.3-vm-fastpaths.md](v0.3-vm-fastpaths.md).

Run from the repository root with Python 3, Lua 5.1, and LuaJIT installed:

```sh
python3 benchmarks/run.py
```

The runner compares original source, Minify, Cff, Vmify, Medium, and Strong.
Production presets retain AntiTamper. Seeds default to 1, 4, and 17, with three
timed batches per generated chunk. Every execution validates a deterministic
checksum against the original source.

The workloads exercise arithmetic and branches, record sorting and aggregation,
string formatting and parsing, and closures with captured state and method
calls. The first chunk argument scales their work; the runner uses scale 1.

Execution measures CPU time for repeated complete chunk invocations after
warmup, excluding parsing. Initialization and allocations in the generated code
are included. Garbage collection runs before each batch and may run during it.
Generation and parsing time are reported separately. Peak memory is not measured.

Reports, raw JSON, and generated sources default to
`test-results/benchmark-v0.3/`. To choose another directory:

```sh
python3 benchmarks/run.py --output /tmp/prometheus-benchmark
```

Other options include `--seeds 1 4 17`, `--samples 3`, `--target 0.1` (target
CPU seconds per batch), `--timeout 60` (seconds per process), and
`--runtimes lua5.1 luajit`. The LuaJIT run leaves JIT enabled. Tiny native JIT
times can produce large slowdown ratios; compare absolute times as well.

Use `--presets Medium` to benchmark a subset of presets against original source.

Use `--resume` with the same output directory and options to reuse completed
measurements after an interrupted run.
26 changes: 26 additions & 0 deletions benchmarks/cases/closures.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
local scale = ... or 1

local function make_processor(seed)
local total = seed
local function add(_, ...)
for index = 1, select("#", ...) do
total = total + select(index, ...)
end
end
return {
add = add,
value = function()
return total
end,
}
end

local checksum = 0
for i = 1, 10000 * scale do
local seed = i % 31
local processor = make_processor(seed)
processor:add(i, seed * 2, i % 7)
checksum = checksum + processor:value()
end

return checksum
16 changes: 16 additions & 0 deletions benchmarks/cases/numeric.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
local scale = ... or 1
local limit = 180000 * scale
local checksum = 0

for i = 1, limit do
local value = (i * 37 + 11) % 997
if value % 5 == 0 then
checksum = checksum + value * 3
elseif value % 2 == 0 then
checksum = checksum + value
else
checksum = checksum - value
end
end

return checksum
14 changes: 14 additions & 0 deletions benchmarks/cases/strings.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
local scale = ... or 1
local format = string.format
local match = string.match
local checksum = 0

for i = 1, 10000 * scale do
local category = (i * 17) % 23
local quantity = (i * 31) % 1000
local record = format("item:%02d|%04d|zone-%d", category, quantity, i % 9)
local parsed_category, parsed_quantity, zone = match(record, "^item:(%d+)|(%d+)|zone%-(%d+)$")
checksum = checksum + tonumber(parsed_category) * 101 + tonumber(parsed_quantity) + tonumber(zone)
end

return checksum
27 changes: 27 additions & 0 deletions benchmarks/cases/tables.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
local scale = ... or 1
local rounds = 100 * scale
local checksum = 0

for round = 1, rounds do
local records = {}
for i = 1, 100 do
local key = (i * 73 + round * 19) % 211
records[i] = { key = key, value = key * 3 + i }
end

table.sort(records, function(left, right)
if left.key == right.key then
return left.value < right.value
end
return left.key < right.key
end)

local subtotal = 0
for i = 1, #records do
local record = records[i]
subtotal = subtotal + record.key * i + record.value
end
checksum = checksum + subtotal
end

return checksum
18 changes: 18 additions & 0 deletions benchmarks/generate.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
-- Generate the actual preset, including AntiTamper, and report compiler CPU time.
local Prometheus = require('src.prometheus')
local presets = require('src.presets')
Prometheus.Logger.logLevel = Prometheus.Logger.LogLevel.Error
local preset, seed, input, output = unpack(arg)
local file = assert(io.open(input, 'rb'))
local source = file:read('*a')
file:close()
local config = {}
for key, value in pairs(assert(presets[preset])) do config[key] = value end
config.Seed = assert(tonumber(seed))
local start = os.clock()
local generated = Prometheus.Pipeline:fromConfig(config):apply(source, input)
local elapsed = os.clock() - start
file = assert(io.open(output, 'wb'))
file:write(generated)
file:close()
print(string.format('%.9f', elapsed))
36 changes: 36 additions & 0 deletions benchmarks/measure.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
-- Parse once; warm the workload, then measure CPU time per complete invocation.
local input, samples, target = arg[1], tonumber(arg[2]), tonumber(arg[3])
io.stdout:setvbuf('no')
collectgarbage('collect')
local start = os.clock()
local run = assert(loadfile(input))
local loadTime = os.clock() - start
local function checksum()
local value = run(1)
assert(type(value) == 'number' or type(value) == 'string', 'scalar checksum required')
return tostring(value)
end
start = os.clock()
local expected = checksum()
-- Warm up JIT traces and interpreter paths before estimating the batch size.
local elapsed = os.clock() - start
local warmups = 1
while elapsed < target and warmups < 1000 do
start = os.clock()
assert(checksum() == expected, 'unstable workload result')
elapsed = elapsed + os.clock() - start
warmups = warmups + 1
end
local repeats = math.max(1, math.min(10000,
math.ceil(target / math.max(elapsed / warmups, 0.000001))))
print('checksum\t' .. expected)
print('load_seconds\t' .. string.format('%.9f', loadTime))
print('repeats\t' .. repeats)
for sample = 1, samples do
collectgarbage('collect')
start = os.clock()
for _ = 1, repeats do
assert(checksum() == expected, 'unstable workload result')
end
print('seconds\t' .. string.format('%.9f', (os.clock() - start) / repeats))
end
147 changes: 147 additions & 0 deletions benchmarks/run.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
#!/usr/bin/env python3
"""Compare real presets on deterministic workloads; retain inputs and raw metrics."""
import argparse
import json
import platform
from pathlib import Path
import statistics
import subprocess

ROOT = Path(__file__).resolve().parents[1]
PRESETS = ('Minify', 'Cff', 'Vmify', 'Medium', 'Strong')


def cpu_model():
cpuinfo = Path('/proc/cpuinfo')
if cpuinfo.exists():
for line in cpuinfo.read_text().splitlines():
if line.startswith('model name'):
return line.split(':', 1)[1].strip()
return platform.processor() or 'unknown'


def command(args, timeout):
result = subprocess.run(args, cwd=ROOT, text=True, capture_output=True, timeout=timeout)
if result.returncode:
raise RuntimeError(f'{args}:\n{result.stdout}\n{result.stderr}')
return result.stdout.strip()


def measure(runtime, path, samples, target, timeout):
fields = {}
for line in command([runtime, 'benchmarks/measure.lua', str(path),
str(samples), str(target)], timeout).splitlines():
key, value = line.split('\t', 1)
fields.setdefault(key, []).append(value)
return dict(checksum=fields['checksum'][0],
load_seconds=float(fields['load_seconds'][0]),
repeats=int(fields['repeats'][0]),
seconds=[float(value) for value in fields['seconds']])


def report(data, output):
lines = ['# Prometheus v0.3 benchmark', '',
f"Host: {data['host']}; CPU: {data.get('cpu', cpu_model())}. Runtimes: "
+ '; '.join(data['versions'].values()) + '.', '',
'Four deterministic workloads compare original source with the actual presets, '
'including AntiTamper. All variants must match the original checksum.', '',
f"Seeds: {data['seeds']}; {data['samples']} timed batches per seed. "
'Each workload is parsed once and warmed up; batches adapt to approximately '
f"{data['target_seconds']} seconds. Execution includes initialization of the emitted "
'chunk, workload work, allocations, and checksum validation, but excludes parsing. '
'Garbage collection runs before each batch; collection during the batch is included.', '',
'Execution figures are median CPU time per full invocation across batches and seeds. '
'Generation and parsing figures are medians across seeds. Output size is a median '
'across seeds. Execution ranges show the lowest and highest per-seed medians. '
'Measurements are local, with JIT enabled for LuaJIT; very small native '
'JIT times can produce large slowdown ratios. These workloads do not establish a '
'general performance guarantee or measure peak memory.', '']
for runtime in data['versions']:
lines.extend([f'## {runtime}', '',
'| Workload | Preset | Execution ms (median; seed range) | Slowdown | Output KiB | Generate ms | Parse ms |',
'| --- | --- | ---: | ---: | ---: | ---: | ---: |'])
for case in data['cases']:
rows = [row for row in data['results'] if row['runtime'] == runtime and row['case'] == case]
baseline = statistics.median(next(row for row in rows if row['preset'] == 'Original')['seconds'])
for preset in ('Original',) + tuple(data.get('presets', PRESETS)):
selected = [row for row in rows if row['preset'] == preset]
seconds = statistics.median([t for row in selected for t in row['seconds']])
seed_times = [statistics.median(row['seconds']) * 1000 for row in selected]
execution = f'{seconds * 1000:.3f}'
if len(selected) > 1:
execution += f' ({min(seed_times):.3f}–{max(seed_times):.3f})'
size = statistics.median(row['bytes'] for row in selected) / 1024
generation = statistics.median(row['generation_seconds'] for row in selected) * 1000
load = statistics.median(row['load_seconds'] for row in selected) * 1000
lines.append(f'| {case} | {preset} | {execution} | {seconds / baseline:.1f}x | '
f'{size:.1f} | {generation:.2f} | {load:.2f} |')
lines.append('')
(output / 'report.md').write_text('\n'.join(lines))


def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--output', type=Path, default=ROOT / 'test-results/benchmark-v0.3')
parser.add_argument('--seeds', type=int, nargs='+', default=[1, 4, 17])
parser.add_argument('--samples', type=int, default=3)
parser.add_argument('--target', type=float, default=0.1)
parser.add_argument('--timeout', type=int, default=60)
parser.add_argument('--runtimes', nargs='+', default=['lua5.1', 'luajit'])
parser.add_argument('--presets', nargs='+', choices=PRESETS, default=list(PRESETS))
parser.add_argument('--resume', action='store_true', help='reuse completed rows in the output directory')
args = parser.parse_args()
if args.samples < 1 or args.target <= 0 or args.timeout < 1:
parser.error('samples, target, and timeout must be positive')
output = args.output.resolve()
output.mkdir(parents=True, exist_ok=True)
cases = sorted((ROOT / 'benchmarks/cases').glob('*.lua'))
assert cases, 'No benchmark cases found'
data = dict(host=platform.platform(), cpu=cpu_model(), seeds=args.seeds, samples=args.samples,
target_seconds=args.target, cases=[case.stem for case in cases],
presets=args.presets, versions={}, results=[])
if args.resume:
previous = json.loads((output / 'results.json').read_text())
for key in ('seeds', 'samples', 'target_seconds', 'cases'):
assert previous[key] == data[key], f'Resume configuration mismatch: {key}'
assert previous.get('presets', list(PRESETS)) == args.presets, 'Resume preset mismatch'
data['results'] = previous['results']
for runtime in args.runtimes:
version = subprocess.run([runtime, '-v'], text=True, capture_output=True, check=True)
data['versions'][runtime] = (version.stdout + version.stderr).strip()
for case in cases:
generated = [('Original', 0, case, 0.0)]
for preset in args.presets:
for seed in args.seeds:
path = output / f'{case.stem}-{preset}-{seed}.lua'
completed = next((row for row in data['results'] if row['case'] == case.stem
and row['preset'] == preset and row['seed'] == seed), None)
if completed and path.exists():
elapsed = completed['generation_seconds']
else:
elapsed = float(command(['lua5.1', 'benchmarks/generate.lua', preset,
str(seed), str(case), str(path)], args.timeout))
generated.append((preset, seed, path, elapsed))
for runtime in args.runtimes:
expected = None
for preset, seed, path, elapsed in generated:
completed = next((row for row in data['results'] if row['case'] == case.stem
and row['runtime'] == runtime and row['preset'] == preset
and row['seed'] == seed), None)
row = completed or measure(runtime, path, args.samples, args.target, args.timeout)
if expected is None:
expected = row['checksum']
assert row['checksum'] == expected, f'Checksum mismatch: {runtime} {path}'
if completed:
continue
row.update(case=case.stem, runtime=runtime, preset=preset, seed=seed,
bytes=path.stat().st_size, generation_seconds=elapsed)
data['results'].append(row)
print(f'{runtime} {case.stem} {preset} seed={seed}: '
f'{statistics.median(row["seconds"]) * 1000:.3f}ms', flush=True)
(output / 'results.json').write_text(json.dumps(data, indent=2) + '\n')
report(data, output)
print(f'Report: {output / "report.md"}')


if __name__ == '__main__':
main()
Loading
Loading