Inspiration
PII redaction is the one case where "on-device" isn't a nice-to-have. The whole value is that the sensitive text never travels — and a cloud redaction API has to receive the unredacted document in order to redact it, which defeats the purpose.
That constraint sets everything else. The model has to be small enough to sit in a phone's cache, quantised enough to run on a CPU with no GPU or NPU, and fast enough to feel instant while you type. That's an optimisation problem, and it's an Arm optimisation problem.
What it does
Paste text, get it back with names, emails, phone numbers, addresses, card numbers and IDs stripped out. No network calls, no cloud, no telemetry, no API key.
$ privlens redact leak.txt
Hi, my name is [PERSON] and you can reach me at [EMAIL] or on [PHONE].
My account [ACCOUNT_NUM] was charged twice on [DATE_OF_BIRTH].
Reference 2019-CX-4 and build 3.2.1 are not personal data.
-- 5 span(s) redacted in 3.1 ms (i8mm kernel, 71.4% of time in GEMM)
Underneath is a from-scratch int8 transformer inference engine in C — no dependencies beyond C11 and a libc — with five hand-written AArch64 kernel tiers and runtime dispatch that picks the fastest one the silicon can actually execute, and never selects one it cannot.
How I built it
A byte-level transformer encoder, 1.47M parameters, tagging every input byte with
one of 12 classes; contiguous runs become a redactable span. Byte-level is a
modelling choice, not a shortcut: PII is overwhelmingly character-patterned — the
@ and dot structure of an email, the digit grouping of a card number, the
NN Streetname St shape of an address. It also means the C engine needs no
tokenizer, no vocabulary file and no Unicode tables. The whole on-device artefact is
one binary plus one 1.5 MB blob.
Quantisation is W8A8 — int8 weights, symmetric per output channel, baked at export; int8 activations, symmetric per row, computed each forward pass. The inner GEMM is a dot product over contiguous int8, which is exactly the shape Arm's SDOT and SMMLA instructions exist to accelerate.
Each tier is its own translation unit compiled with its own -march, so one
portable binary contains every path and chooses at run time — the same structure
llama.cpp uses for GGML_CPU_ALL_VARIANTS. All five produce bit-identical
output; integer GEMM has no reassociation error, so "close enough" isn't the bar.
The optimisation that mattered
The first SMMLA kernel lost to SDOT. That wasn't the expected result.
SMMLA needs its two operands as interleaved row pairs inside one 16-byte register.
Building that with vcombine_s8 costs two 8-byte loads plus a combine per operand,
every iteration — and at that ratio the extra arithmetic SMMLA gives you is entirely
consumed by data movement.
The fix was to store the weights already interleaved, once, at model load. The inner
loop collapses to ldr / ldr / ldr / smmla / smmla. Measured on an identical GEMM
(M=8, N=32, K=192), counting dynamic AArch64 instructions actually retired:
| Tier | Instructions | int8 MACs/instr | vs scalar |
|---|---|---|---|
| scalar | 37,998 | 1.29 | 1.00x |
| dotprod | 11,414 | 4.31 | 3.33x |
| i8mm, naive | 16,961 | 2.90 | 2.24x |
| i8mm, packed | 8,233 | 5.97 | 4.62x |
Packing is a 2.06x improvement over the naive SMMLA kernel, and it takes it from
losing to SDOT to beating it by 1.39x. This is the same insight behind llama.cpp's
CPU_REPACK buffer type — arriving at it independently, from a measurement that
contradicted my expectation, is the most useful thing this project did.
Where it got interesting: instructions are not cycles
Two independent bodies of evidence, and they disagree. Modelled with llvm-mca
against LLVM's real per-core scheduling models, packed SMMLA retires 28% fewer
instructions than SDOT — and on Neoverse-V1 it is still slower in cycles, while on
Neoverse-V2 it wins by 1.6x.
SMMLA's throughput relative to SDOT is a microarchitectural property, and no
compiled-in rule gets it right on every core. So PrivLens doesn't guess:
privlens tune runs the real workload through every kernel the CPU can execute, on
the device that will run it, and caches the winner under a hardware signature. Copy
that file to a different machine and the signature won't match, so it's ignored —
and the loader re-verifies executability against the live CPU before honouring it,
because a config file must never be able to talk the engine into an illegal
instruction.
Never SIGILL: the CPU probe
src/arm_features.c is standalone and dependency-free, and it's the piece most
likely to be useful on its own. AArch64 feature detection is harder than it looks,
and getting it wrong doesn't degrade gracefully — it traps.
- Naming. Linux
/proc/cpuinfocalls FEAT_DotProdasimddp. Arm docs, GCC-marchmodifiers and Apple's sysctl all call itdotprod. Scripts that grep fordotprodsilently fall back to baseline Armv8 on hardware that has it — this is Stockfish #4931, observed on a Raspberry Pi 5. - Android under-reports. Many shipped kernels fail to advertise
HWCAP_ASIMDDPon cores that implement it, so PrivLens falls back to a MIDR allowlist, mirroringpytorch/cpuinfo. Additive only — never used to disable something HWCAP reported. - SME is not SME2. llama.cpp #22182 selects SME2 kernels on a base-SME
Snapdragon X2 and dies with
STATUS_ILLEGAL_INSTRUCTIONbefore emitting a log line. - Vector length isn't in HWCAP.
prctl(PR_SVE_GET_VL)is the only way to learn it, and it's decisive.
The contract: pl_cpu_detect() never executes a feature-gated instruction in order
to test for that feature. Everything comes from the OS — auxv, sysctl, prctl, Win32.
Verified across seven Arm CPUs
The same statically-linked binary, run under qemu-aarch64 against seven emulated
cores, asserting bit-identical GEMM output on every one:
cortex-a72 Raspberry Pi 4 selects neon
cortex-a76 Raspberry Pi 5 selects dotprod
neoverse-n1 AWS Graviton2 / Ampere Altra selects dotprod
neoverse-v1 AWS Graviton3 selects sve
neoverse-n2 Azure Cobalt 100 selects i8mm
cortex-a710 Snapdragon 8 Gen 1/2 selects i8mm
max QEMU max (all features) selects sve
Look at Graviton3 and Cobalt 100. Identical i8mm and sve feature bits. The only
difference is the SVE vector: 256-bit versus 128-bit. On a 128-bit implementation,
SVE SDOT consumes exactly the same 16 bytes per instruction as NEON SDOT while
paying for predicate setup — so it's worse, and packed SMMLA is the right choice.
The feature bit tells you nothing; the vector length decides. Selecting on
HWCAP2_SVE2 alone, which plenty of code does, gets this wrong.
That prediction then held on real hardware. CI's native-Arm runner is a Neoverse N2
(MIDR 0x410fd490) reporting SVE VL = 16 bytes (128-bit), and it selected i8mm
— exactly what the emulated matrix said it should.
And it's worse on phones than on servers: Qualcomm's Oryon cores (Snapdragon 8 Elite,
X Elite) dropped SVE entirely. LLVM's oryon-1 target lists FeatureDotProd and
FeatureMatMulInt8 and conspicuously no FeatureSVE. An inference path gated on
SVE2 falls off a cliff on Qualcomm's current flagship.
The mistake that shaped the training data
tools/gen_data.py synthesises the entire corpus — no download, no Hugging Face, no
pretrained checkpoint.
The first version emitted single sentences. The model trained on it scored 0.947
span-F1 on held-out data and then fell over on a realistic email: it labelled the
subject line as PERSON, and so did it label the word "Thanks". The architecture was
fine. The data was the problem — every capitalised token the model had ever seen at
the start of a line was a name.
So the corpus now generates whole documents: emails with subject lines, greetings and sign-offs; chat threads; form dumps; server logs. It deliberately supplies capitalised non-entities as hard negatives — weekdays, months, "Thanks", "Subject", "Approved", version strings, ticket and order references. Roughly one document in ten contains no PII at all, so the model is rewarded for staying quiet.
For a redactor, "capitalised means PERSON" is the single most damaging error available: it destroys ordinary prose while giving a false sense of safety.
Model quality
Trained for 4 epochs on 90,000 synthetic documents, evaluated on 6,000 held-out ones.
tools/validate.py then runs the shipped C binary over 150 held-out documents
and scores it against the same gold labels:
| precision | recall | span-F1 | |
|---|---|---|---|
| PyTorch float32, raw argmax | 0.904 | 0.967 | 0.934 |
| C engine, int8 + span cleanup | 0.933 | 0.925 | 0.929 |
117 of 150 documents produce a span set byte-identical to the float reference. int8 costs 0.005 span-F1, and the split is informative: the engine trades a little recall for three points of precision, which is the cleanup suppressing exactly the "capitalised word tagged as PERSON" errors that wreck real prose.
Challenges I ran into
I had no Arm hardware. Everything was cross-compiled and verified under
qemu-aarch64, which meant being disciplined about what emulation can and cannot
tell you. QEMU executes every guest instruction, so tracing and differencing gives an
exact retired-instruction count — but wall-clock under emulation is meaningless. SVE
even appears to be the slowest tier under QEMU, which is translation cost, not
silicon. So the repo labels every number by how it was established: executed, traced,
or modelled. No blending.
SME defeated me, and I shipped that fact instead of a broken kernel. I prototyped
an SME kernel far enough to execute under QEMU with the right tile geometry — which
needed clang 18 plus a hand-written AAPCS64 SME ABI shim, since GCC 13 has no SME
support at all. Its results were wrong, and the SMOPA operand-to-tile mapping
didn't probe consistently. Without real SME silicon there's no way to tell whether
I'm misreading the ACLE or the emulator differs from hardware. So it isn't shipped,
and docs/OPTIMISATION.md §7 documents the attempt.
Ragged span edges. A byte tagger will label Priy of Priya, or split a card
number across its spaces. Emitting Pr[PERSON]a both leaks two letters of the name
and mangles the sentence. Three deterministic passes fix it — snap edges outward to
word boundaries (capped, so one mislabelled byte can't drag in a whole word), merge
neighbours across short non-alphabetic gaps, then trim sentence punctuation back off.
Because all three operate on argmax labels only, every kernel tier still produces
identical output.
What I learned
That a measurement contradicting your expectation is the most valuable thing you can get, provided you chase it instead of explaining it away. The naive SMMLA kernel losing to SDOT is the reason this project has a packing pass at all.
And that "compiled in" and "executable here" are genuinely independent properties. Almost every ecosystem bug I read while building this — the Stockfish naming trap, the llama.cpp SME2 crash — comes from collapsing those two into one.
What's next
Threading, measured properly rather than guessed — it interacts with big.LITTLE
scheduling in ways that deserve their own experiment. An SME kernel, if I can get
access to real SME silicon. And src/arm_features.{h,c} is ~400 lines with no
dependencies and is the piece I'd most like other people to just take.
Log in or sign up for Devpost to join the conversation.