Skip to main content

Two Things We Got Wrong About erpl-rev's Speed

· 9 min read
Joachim Rosskopf
Co-Founder & CEO

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.

A duck engineer pointing at a flamegraph with one wide bar struck out and the layers beneath it shortened, the SDK toolbox closed and pushed aside, a Rust gear wired to the machine instead

The staging table I bragged about

Here is the sentence from the June post, verbatim:

Vectorized ingest. Each package lands via a DuckDB Appender into a staging clone, then one INSERT … SELECT with 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_view into the interned name now.
  • Interned names lived in a hash map keyed by element id. readId is 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:

beforeafter
decode1,604 ms622 ms2.58×
decode + append + apply3,994 ms1,605 ms2.49×
throughput12,519 rows/s31,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:

Shapebeforeafter
In-process ingest only (50k × 420 cols)12,519 rows/s31,153 rows/s2.49×
100k rows, 420 cols, 1 worker19 s13 s1.46×
3M rows, 420 cols, 5 workers190 s158 s1.20×
10M rows, 50-col slice, 5 workers82 s69 s1.19×
10M rows, 50-col slice, 4 workers91 s86 s1.06×
10M rows, 50-col slice, 2 workers122 s122 s1.00×

Speed-up by workload shape: 2.49x with no SAP in the loop, 1.46x for a single-worker 420-column load, 1.20x for a 5-worker 420-column load, 1.19x and 1.06x for 5 and 4 workers on a 50-column slice, and exactly 1.00x at 2 workers on a 50-column slice

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 turn dlopens a set of ICU libraries by name, plus we link libduckdb.

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.