Two Things We Got Wrong About erpl-rev's Speed
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.
Two of those explanations have since turned out to be wrong, in opposite directions. One thing I singled out as making it fast was in fact costing a third of the ingest path. And one thing I treated as an immovable constraint — SAP's proprietary RFC library — turned out to be removable altogether.

The staging table I bragged about
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.
The 230× against row-by-row was real. The staging clone was not the reason.
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 this, and I had not noticed it:
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 that never needed to exist
The other half of the profile was our own BXML decoder, and it was worse.
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.
One caveat, stated plainly: the release pipeline still builds with the SAP
SDK. The SDK-free build is green and proven live, but if you download today's
v2026.06.03 bundle you will still find the SAP libraries inside it. Switching
the release over is the next step, not a thing already done.
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.
