erpl-rev: 2.5× Faster Ingest, and the SAP SDK Is Gone
In June I wrote up erpl-rev — the little registered RFC server that lets ABAP call out into DuckDB — and it replicated 10 million rows of a 400-column BSEG-shaped table in about a minute. That post did not just claim a number, it explained why the number was what it was.
Since then I put it under a profiler to find out what was still on the table. Two things came back, pulling in opposite directions. A step that genuinely earned its place in June still had about a third of the ingest path left in it. And a constraint I had treated as fixed — SAP's proprietary RFC library — turned out to be removable altogether.

What the flamegraph showed
Here is the sentence from the June post, verbatim:
Vectorized ingest. Each package lands via a DuckDB
Appenderinto a staging clone, then oneINSERT … SELECTwith casts — about 230× faster than the naive row-by-row path.
That 230× was real, and the staging clone is what delivered it. What a profiler showed later is that there was still about a third of the path to reclaim underneath it.
A flamegraph of a 50,000-row × 420-column package put ~16% of ingest inside
StringStats::Update, UncompressedStringStorage::StringAppend,
Utf8Proc::Analyze and HyperLogLog::Update. Those are DuckDB doing its job
properly: computing string statistics, running UTF-8 analysis, building
HyperLogLog distinct-count sketches, dictionary-compressing segments. All of it
correct, all of it useful — for a table you are going to keep.
Our staging clone was written once, scanned exactly once by the following
INSERT … SELECT, and then thrown away. We were paying full storage price for
a table with a lifetime of about two seconds.
DuckDB has exactly the right tool for skipping that:
QueryAppender. You hand it a SQL statement and a set of column types, append
your rows into it, and on flush it injects the accumulated ColumnDataCollection
into that statement as a never-materialized CTE. No table, no storage layer, no
statistics — the rows go straight into the INSERT or MERGE INTO that
consumes them.
The awkward part of the change is that the statement now has to exist before
the first row is appended, so building the projection and the MERGE/INSERT
SQL moved from after the decode loop into the callback that fires when the
column list arrives. The whole append then runs inside one explicit transaction,
so an oversized package still cannot half-apply.
Twenty-one million allocations we can skip
The other half of the profile was our own BXML decoder, and it had even more to give.
Decode materialised the entire package as a vector<vector<string>> before
handing it to the ingest layer — for a 50,000-row, 420-column package that is
21 million std::string allocations and roughly half a gigabyte resident,
built solely so the next loop could copy every cell straight back out into
DuckDB's vectors.
The streaming form hands each row to a callback as string_views into the
payload buffer, so the copy into DuckDB is the only copy that happens. Two
smaller things fell out of the same profile:
- Every element carried an owned name string — one allocation to copy it out
of the interned map, one to move it into the element stack. That is 42 million
allocations for a name that is read exactly once, on the first row, to build
the column list. It is a
string_viewinto the interned name now. - Interned names lived in a hash map keyed by element id.
readIdis a one- or two-byte form capped at 4095, so the id space is small, dense and known — a flat table indexed by id replaced an int hash on every element of every row.
The numbers, and where they stop
The in-process ingest benchmark (test/bench_ingest.cpp, no SAP involved,
median of 5 runs) is unambiguous:
| before | after | ||
|---|---|---|---|
| decode | 1,604 ms | 622 ms | 2.58× |
| decode + append + apply | 3,994 ms | 1,605 ms | 2.49× |
| throughput | 12,519 rows/s | 31,153 rows/s |
Now the honest part. That 2.5× is what the client does. It is not what you get end to end, because the SAP side of the pipe does not get faster. Same machine, same A4H trial, arms run alternately so that a busy period on the host hits both equally, a fresh DuckDB file for every single sample, medians rather than best-of:
| Shape | before | after | |
|---|---|---|---|
| In-process ingest only (50k × 420 cols) | 12,519 rows/s | 31,153 rows/s | 2.49× |
| 100k rows, 420 cols, 1 worker | 19 s | 13 s | 1.46× |
| 3M rows, 420 cols, 5 workers | 190 s | 158 s | 1.20× |
| 10M rows, 50-col slice, 5 workers | 82 s | 69 s | 1.19× |
| 10M rows, 50-col slice, 4 workers | 91 s | 86 s | 1.06× |
| 10M rows, 50-col slice, 2 workers | 122 s | 122 s | 1.00× |

That last row is the one worth sitting with. At two workers on a narrow slice, the two builds are not merely close — their distributions are identical, 121 to 123 seconds on both arms across seven rounds each. Every second of that run is SAP reading and serialising; the client could be twice as fast again and the wall clock would not move.
This is just Amdahl's law arriving on schedule, and the June post already pointed at it when it noted that per-worker throughput tapers as workers contend for the SAP read side. Optimising the client pays in proportion to how much of the wall clock the client owns: everything at 2.5× when there is no SAP in the loop, 1.46× for a serial wide load, 1.20× once five workers share it, and nothing at all when two workers already saturate the read side with a narrow projection.
Two things improved regardless. The first is consistency: across the wide 5-worker runs the old code ranged 167–193 s while the new code sat at 157–164 s, and the same tightening shows wherever there was spread to begin with — at four workers the old path wandered between 75 and 105 s, the new one between 84 and 86 s. The second is memory — peak server RSS on the 3M-row wide load dropped from 12.1 GB to 9.0 GB, because those staging tables were real, resident data.
And the SDK is gone
The June post had a section called "One file to ship", and it opened like this:
A SAP RFC server has an awkward dependency footprint: it links
libsapnwrfc, which in turndlopens a set of ICU libraries by name, plus we linklibduckdb.
We solved that by bundling — a self-extracting binary with the SAP SDK and ICU riding along inside. It works, and it was the right call at the time. But the better answer is not to need them.
erpl-rev now builds against erpl-proto,
our pure-Rust implementation of the RFC protocol. It is clean-room work:
every rule in the specification is derived from packet captures of SAP's own
libsapnwrfc talking to a live ABAP system, validated byte-for-byte by
re-encoding captured records and by requiring a real SAP server to answer our
bytes exactly as it answered SAP's library. No disassembly, no reading anyone
else's source. The protocol post walks
through the wire format and the provenance argument in detail.
The compatibility trick is that erpl-proto exposes SAP's own C ABI. erpl-rev
is not ported to a new API — it is recompiled against a different implementation
of the same 19 Rfc* C entry points it already called, and links it statically. Built that way, the
server's entire non-system dependency list is:
$ ldd build/erpl_rev_server
libduckdb.so => vendor/duckdb-1.5.4/libduckdb.so
libm.so.6, libc.so.6, libstdc++.so.6, libgcc_s.so.1, libdl.so.2, libpthread.so.0
No libsapnwrfc, no libsapucum, no ICU — not even erpl-proto's own shared
object, because it is inside the binary. DuckDB is the only library left in the
payload.
"Compatible" is a claim that deserves evidence, so: the same 13-stage live
end-to-end suite runs against a real A4H system on both backends and passes on
both, including the stage that compares every replicated cell against its SAP
source, and the delta/MERGE paths. Plus 86 test cases and 16,475 assertions
in the unit suite.
All three release builds now link erpl-proto statically — Linux, macOS and
Windows. None of them fetches the SAP SDK, and each one fails its build if
anything RFC- or ICU-shaped ends up linked. DuckDB is the only library left in
the payload.
The evidence behind that is not equal across the three, and it is worth saying so rather than letting one number stand for all of them. Linux is what the 13-stage suite runs against a live ABAP system, every cell of a replicated table compared against its source. macOS and Windows are verified to compile, link, start and answer the SDK's version call — no more than that. Neither has yet talked to a real SAP system.
That gap was wider than it looked: until this week nothing in erpl-proto had
ever been compiled off Linux, only type-checked, and the very first Windows
build failed on a POSIX call that had sat there unnoticed. Which is the argument
for building on a platform before claiming to support it.
That is all shipped, as of v2026.08.30 — the first release with no SAP
libraries in any bundle. It is also the first on PyPI, which makes trying it a
one-liner:
uvx erpl-rev --smoke
# erpl-rev smoke ok: RFC backend erpl-proto 0.0.1 (0.0.1); DuckDB {"v":"v1.5.4"}
uvx erpl-rev --db erpl-rev.duckdb
No download, no unpacking, no LD_LIBRARY_PATH. The second line starts the
server, which registers its PROGRAM_ID at the gateway and waits for ABAP to
call out to it; pip install erpl-rev works too if you would rather have it on
PATH, and the single-file binaries and the Docker image are still there for
hosts without Python.
Where this goes
The measurements point somewhere specific. The client is no longer the bottleneck for partitioned loads — SAP's read side is, and it has been all along. The remaining lever on our side is server-side columnar ingest, so that a package arrives in a form DuckDB can consume without a per-cell conversion at all. That is a bigger change than anything in this post.
The code is DataZooDE/erpl-rev, and the ingest work is PR #67.
If the clean-room side of erpl-proto is the part that interests you — how you
build a protocol implementation from captures and prove it correct, and what
that means for getting your own data out of SAP — come argue with me about it
on LinkedIn. It is the part I
most enjoy talking about.
