Repository navigation
Description
Observation
I run the native wheel (arcadedb==26.11.1.post2) inside a small host daemon, and a tiny database costs more resident memory than I expected. One process per engine at nice 19: open a new database, insert 1,000 rows of about 100 bytes, run one count(*), close. Python 3.12, Linux 7.0 on an Intel Core Ultra X9 388H, engine defaults except the last row. This is one machine and one run, and it is not a benchmark of speed or of larger data: it asks what an embedded engine costs in resident memory before it holds anything.
| engine | peak resident (MiB) | over a bare Python process (MiB) | after the work: anonymous / file-backed (MiB) |
|---|---|---|---|
python |
12.5 | 0.0 | 4.0 / 8.6 |
sqlite |
13.9 | 1.3 | 4.4 / 9.4 |
duckdb |
83.6 | 71.0 | 23.2 / 44.4 |
arcadedb-default |
93.0 | 80.5 | 33.2 / 59.8 |
arcadedb-log-tuned |
89.9 | 77.3 | 29.3 / 60.5 |
The last row sets ARCADEDB_NATIVE_ISOLATE_ARGS="-Xmx64m -Darcadedb.maxPageRAM=8". It moved the total by about 3 MiB, so I think the floor is the runtime and not the caches, but I have not shown that.
Where it sits
/proc/self/smaps of a separate process doing the same kind of work (create a vertex type, insert 1,000 rows in one transaction, close), largest mappings first:
| resident (MiB) | mapping |
|---|---|
| 46.5 | libarcadedb.so, the wheel's native library |
| 22.0 | anonymous memory |
| 10.0 | the Python interpreter |
| 3.9 | [heap] |
| 1.9 | libc.so.6 |
| 0.7 | libm.so.6 |
| 0.4 | locale-archive |
libarcadedb.so is 160.5 MiB on disk, and 46.5 MiB of it is resident after this tiny workload. In real use inside the daemon the process stays at about 100 MiB resident on two hosts, because the library stays mapped after the database is closed.
What I do not know
- Which pages of the library are touched at import, at
create_databaseand at the first query, and how much of the resident part is never used again. - Whether the image heap or class initialization at startup decides the anonymous part.
- How the JVM wheel (
arcadedb-jvm) and the previous native wheel compare on the same workload.
Plan
- Page-level breakdown of the library's mappings at three points: after
import arcadedb, aftercreate_database, after the workload. - The same workload on
arcadedb-jvmand on the previous native wheel. - Check the native-image build options for the image heap and the GC and what each costs in resident memory.
- If part of it is owned by the engine (for example what class initialization pulls in at startup), file a separate issue in ArcadeData/arcadedb with a reproducer on the engine and link it here.
Reproduce
"""One process, one engine: open a database, insert 1,000 rows of about 100 bytes, run one query, close. Prints one JSON line with the process memory (kB) from
/proc/self/status before the engine is imported and after the work (VmHWM is the peak). Not a benchmark: it answers 'what does an embedded engine cost in resident memory
for a tiny database'."""
import json
import sys
import time
def status():
d = {}
for line in open("/proc/self/status"):
k, _, v = line.partition(":")
if k in ("VmRSS", "VmHWM", "RssAnon", "RssFile"):
d[k] = int(v.split()[0])
return d
engine, path = sys.argv[1], sys.argv[2]
out = {"engine": engine, "before": status()}
t0 = time.perf_counter()
n = None
if engine == "python":
n = 0
elif engine == "sqlite":
import sqlite3
c = sqlite3.connect(path + "/t.sqlite")
c.execute("create table t(k integer primary key, v text)")
c.executemany("insert into t values(?,?)", [(i, "x" * 100) for i in range(1000)])
c.commit()
n = c.execute("select count(*) from t").fetchone()[0]
c.close()
elif engine == "duckdb":
import duckdb
c = duckdb.connect(path + "/t.duckdb")
c.execute("create table t(k integer primary key, v varchar)")
c.executemany("insert into t values(?,?)", [(i, "x" * 100) for i in range(1000)])
n = c.execute("select count(*) from t").fetchone()[0]
c.close()
elif engine.startswith("arcadedb"):
import arcadedb
raw = arcadedb.create_database(path + "/adb")
raw.command("sql", "CREATE VERTEX TYPE T")
raw.command("sql", "CREATE PROPERTY T.k INTEGER")
with raw.transaction():
for i in range(1000):
raw.command("sql", "INSERT INTO T SET k = ?, v = ?", i, "x" * 100)
n = raw.query("sql", "SELECT count(*) AS n FROM T").to_list()[0]["n"]
raw.close()
else:
sys.exit("unknown engine " + engine)
out["after"] = status()
out["rows"] = n
out["seconds"] = round(time.perf_counter() - t0, 3)
print(json.dumps(out))Run it once per engine, for example uv run --python 3.12 --with arcadedb==26.11.1.post2 python -I memcmp.py arcadedb-default /some/empty/dir, with an empty directory per run. If someone already knows a build or runtime option that lowers the floor, tell me and I will measure it on the same workload.
Activity
tae898 commentedon Nov 11, 2026
Summary. After the 1,000-row workload 48.2 MiB of the 160.5 MiB native library is resident, almost all of it clean file-backed pages: 26.6 MiB in
.textand 21.1 MiB in.svm_heap(the image heap); the library is not mapped at all until the first engine call. The JVM wheel peaks at 178.4 MiB against 92.8 MiB for the native one (1.9 times in peak resident, 2.1 times over a bare Python process), and the previous stable native wheel,26.11.1, peaks 4.7 MiB lower than26.11.1.post2, for a reason I did not find. The native build sets an optimization level and a CPU baseline but no GC or heap option, and the image holds the full server stack. The candidate changes at the end are hypotheses to measure; nothing was built or tried.Results for the first three checklist items, all on the laptop from the issue (Intel Core Ultra X9 388H, Linux 7.0, Python 3.12, 4 KiB pages), every process at
nice -n 19, one process at a time, each one fresh and a few seconds long, databases on disk. The workload is the one ofmemcmp.py: create a vertex type, insert 1,000 rows of about 100 bytes in one transaction, onecount(*), close. I did not drop the page cache, so the library file was cached in every run. This is still one machine, and I read nothing here as a statement about larger data or about speed.1. Where the native library's resident pages are
I wrote a probe in standard-library Python that runs the workload and, at five points, reads
/proc/self/smapsand/proc/self/pagemap(the present bit of every page of every mapping oflibarcadedb.so, readable without privileges). It parses the ELF section headers of the library withstructand maps each page to the section that holds it by its file offset, so the tables below are by ELF section. The library has the sections of a native-image shared library:.text(code, 110.3 MiB on disk),.svm_heap(the image heap, 49.6 MiB) and little else; there is no.symtab. The numbers are medians of 3 runs. The probe itself adds about 0.9 MiB of anonymous memory overmemcmp.py(34.1 against 33.2 MiB at the end).The library is not mapped at all after
import arcadedb: the wheel loads it withctypes.CDLLat the first call that needs the engine, so the "after import" column is zero and the 10.7 MiB thatimport arcadedbadds (8.2 MiB anonymous, 2.5 MiB file-backed, 205 modules insys.modules) is the Python side only. I added a column for the point after the library and the isolate are loaded but before any database exists, to separate that step fromcreate_database.import arcadedb, MiBcreate_database, MiB.text.rodata.svm_heapRsssummed over the library's mappings insmapsAfter the workload 48.2 MiB of the library is resident (the issue had 46.5 MiB with a slightly different workload).
.textholds 26.6 MiB of it (24% of its 110.3 MiB on disk, 55% of the resident library) and.svm_heapholds 21.1 MiB (43% of its 49.6 MiB); everything else together is 0.4 MiB. Only 0.59 MiB of the library's resident pages are private copies (copy-on-write), so what is resident is almost entirely clean file-backed pages, which the kernel can drop and re-read from the file. By phase the library's resident pages grow by 7.54 MiB when it is loaded, by 26.26 MiB increate_databaseand by 14.39 MiB in the workload, so creating the database costs more than the 1,000 inserts.Were those pages touched by the engine, or only mapped ahead? I can bound it and I can say what the pattern looks like, but I cannot read per-page access information without root. First,
ReferencedequalsRssin every mapping at every checkpoint (true), so on its own it cannot separate the two. Second, I cleared the referenced bits at each checkpoint (/proc/self/clear_refs) in separate runs and readReferencedagain at the next one. In the load phase all 7.54 MiB gained are referenced, increate_database28.85 MiB are referenced for 26.26 MiB gained, and in the workload 22.25 MiB for 14.39 MiB gained, so in those phases only 2.6 and 7.9 MiB of the pages that were already resident were touched again; the rest of what was resident stayed untouched during the phase. Third, the present pages come in whole 64 KiB windows: 12144 of the library's 12337 present pages (98%) sit in windows that are completely present (759 of 779 windows), for.text99% (423 of 431 windows). A completely present 64 KiB window is what the kernel's fault-around produces on a read fault when the pages are in the page cache (the kernel default is 64 KiB; the setting is in debugfs, which needs root, so I did not read it here); sparse single-page touches cannot produce it. So my reading is that most of the resident library is mapped by fault-around around a much smaller set of pages the engine really executes or reads. That set is at least one page per window with a present page, 3.0 MiB, and at most all 48.2 MiB; I cannot narrow it further from here.The library's mappings by phase, with the referenced bits per phase
r--p.svm_heap at 110.9 MiBrw-p.svm_heap at 127.9 MiBrw-p.svm_heap at 128.1 MiBr--p.svm_heap at 138.0 MiBr--p.rela.dynr-xp.text at 0.1 MiBr--p.rodata at 110.6 MiBr--p.data.rel.ro at 110.7 MiBrw-p.svm_heap at 110.8 MiBrw-p.bss (anonymous tail)The library's mappings at the end of the workload, with the 64 KiB window counts
Rss, MiBAnonymous, MiBReferenced, MiBr--p.svm_heap at 110.9 MiBrw-p.svm_heap at 127.9 MiBrw-p.svm_heap at 128.1 MiBr--p.svm_heap at 138.0 MiBr--p.rela.dynr-xp.text at 0.1 MiBr--p.rodata at 110.6 MiBr--p.data.rel.ro at 110.7 MiBrw-p.svm_heap at 110.8 MiBrw-p.bss (anonymous tail)The anonymous part is not in the library. Process-wide, by checkpoint:
26.11.1.post2, median of 3)libarcadedb.sopages, MiBimport arcadedbcreate_databaseThe anonymous memory grows by 18.6 MiB during the workload (from 15.5 to 34.1 MiB), after
create_databasehad added only 2.3 MiB. One anonymous mapping becomes the largest one in that phase:With the default isolate arguments
-XX:+PrintGClogs no collection at all during the workload, so the allocation of those 1,000 inserts stays in memory uncollected. WithARCADEDB_NATIVE_ISOLATE_ARGS="-Xmx64m -XX:+PrintGC"(the issue's last row) one collection runs,13.00M->3.00M, and the anonymous memory at the end is 29.4 MiB instead of 33.2 MiB. I have not shown that the large anonymous mapping is the Java heap; the PrintGC result is consistent with it and not proof.2. The same workload on the other builds
Each arm ran once as a warm-up that I do not count (it fills the
uvenvironment and covers any first-start effect, and it is the last column), then three counted runs, interleaved arm by arm, each in a fresh process with an empty database directory. The newest earlier release ofarcadedbon PyPI is26.11.1(uploaded 2026-11-05).26.12.1.dev2(2026-11-07) was also uploaded before26.11.1.post2but sorts above it as a version, so I measured it too. Values are the median with the minimum and maximum of the three runs; "over a bare Python process" subtracts the median of the bare Python runs.libarcadedb.soon disk, MiBarcadedb==26.11.1.post2(native, control)arcadedb==26.11.1(native, previous stable release)arcadedb==26.12.1.dev2(native, dev release of 2026-11-07)arcadedb-jvm==26.11.1.post2(JVM through JPype)The control reproduces the issue (80.3 MiB over bare Python, the issue had 80.5). The JVM wheel is 178.4 MiB peak, 165.9 MiB over bare Python, 2.1 times the native control; its anonymous memory is 137.0 MiB (native 33.2) and its file-backed memory 41.5 MiB (native 59.5).
26.11.1is 4.7 MiB lower than26.11.1.post2, and26.12.1.dev2is 2.7 MiB lower. All three native builds were built with the same GraalVM CE,-O3and-march=compatibilityand carry engine26.11.1(the dev build reports26.12.1-SNAPSHOT), but from different commits of the bindings (2f28a32for26.11.1,1454809forpost2).I ran the same section probe on
26.11.1. The libraries are almost the same size on disk (160.3 and 160.5 MiB), but after the workload26.11.1has 43.6 MiB of the library resident andpost2has 48.2 MiB:post2has 3.2 MiB more in.textand 1.4 MiB more in.svm_heap. I did not find the cause. It is not the engine release (both carry26.11.1); it could be the Java glue, what the Python side calls, or the layout of a different build.26.11.1: size on disk, MiB26.11.1: resident after the workload, MiB26.11.1.post2: size on disk, MiB26.11.1.post2: resident after the workload, MiB.text.rodata.svm_heapHow the JVM wheel starts its JVM: it loads the JRE that is bundled in the wheel through JPype (
jpype.startJVM) with the options the wheel builds in_build_jvm_args; I read them from the running JVM (RuntimeMXBeanand the HotSpot diagnostic bean) aftercreate_database. The two lists agree, and the wheel adds no-Xms, no-Xmxand no collector option, so the collector and the initial heap are the JVM defaults for this host (cores and RAM as in the table).UseG1GC=true,UseSerialGC=false; MXBeans: G1 Young Generation, G1 Concurrent GC, G1 Old Generation)-XX:MaxRAMPercentage=50added by the wheel;MaxHeapSize= 15544 MiB on this host (half of its 30 GiB of RAM)-Xms;InitialHeapSize= 488 MiB (JVM default, 1/64 of RAM);Runtime.totalMemory()= 32 MiB when probed-XX:+UseCompactObjectHeadersadded by the wheel (UseCompactObjectHeaders=true)ReservedCodeCacheSize= 240 MiB,CICompilerCount= 12, tiered up to level 4AOTCacheis empty in the running JVM (the wheel enables it only withARCADEDB_AOToraot=True, default off)classes_coh.jsa(14.4 MiB) inlib/server; the JVM uses its default archive unless-Xshare:off(not verified in this run)The JVM arguments in effect
The arguments, with the per-user log directory replaced by
<home>:3. How the native library is built
I read the build files of the bindings repository at commit
749c804(the checkout I had; nothing was built or changed). The library in the26.11.1.post2wheel records commit1454809, which is not in that checkout, so I compared git blob IDs of the build files at that commit through the GitHub API:pom.xml,build-image.sh,native-image.properties,reachability-metadata.json,select_native_matrix.pyare identical, andlib.pydiffers, which I therefore took from the installed wheel instead. The table lists the memory-relevant options; each value is extracted by script from the saved files, and the script also asserts that no--gc,-R:,MaxHeapSize,MinHeapSize,quickBuildor PGO option appears anywhere in them.-O3build-image.shpasses it; thepom.xmldefault is-Os-march=compatibilitybuild-image.sh(baseline x86-64); the wheel recordsGraalVM CE 25.4.4.1.1, -O3, -march=compatibility--gc=option anywhereGC(0) Pause Incremental GCappears in the PrintGC log of the-Xmx64mrun in section 1)-R:option, noMaxHeapSizeorMinHeapSizepom.xml,build-image.sh,native-image.properties, the wheel loader-XX:MaximumHeapSizePercent=50-Xmx,-XX:MaximumHeapSizePercent=or-XX:MaxRAM=ARCADEDB_NATIVE_ISOLATE_ARGSis appended in front (-Xmx64mof the issue's last row came this way)<sharedLibrary>true</sharedLibrary>)native-maven-plugin1.1.14,--no-fallback-J-Dfastops.mode=1 -J-Xmx12g -H:-VectorAPISupport -H:+PrintAnalysisCallTree -march=compatibilityNATIVE_IMAGE_OPTIONSinbuild-image.shwith the values of the Linux x86-64 entry ofselect_native_matrix.py(NI_EXTRAis empty there);-J-Xmxis the builder's heap, not the library's--add-modules=jdk.incubator.vector,-H:-VectorAPISupportio.github.jbellis.jvector.vector.cnative.FastMode, io.github.jbellis.jvector.vector.cnative.FfmKernels,org.slf4j, org.slf4j.helpers, org.slf4j.spi, org.slf4j.event, org.slf4j.julpom.xml; everything else follows the GraalVM defaultorg.wildfly.common.net.HostNamepom.xml-H:PriorityForceInline)native-image.properties; resource bundleXMLMessagesincludedreachability-metadata.json(120791 bytes);-H:IncludeResources=.*is not usedhttp,httpspom.xmlarcadedb-bolt,arcadedb-console,arcadedb-engine,arcadedb-graphql,arcadedb-integration,arcadedb-mcp,arcadedb-network,arcadedb-postgresw,arcadedb-redisw,arcadedb-serverpom.xmldependencies (plus jvector 4.0.2, jboss-threads, smallrye-common, slf4j-jdk14)com.arcadedb:arcadedb-gremlin,com.arcadedb:arcadedb-grpcw,com.arcadedb:arcadedb-ha-raft,com.arcadedb:arcadedb-metrics,com.arcadedb:arcadedb-mongodbw,com.arcadedb:arcadedb-tracing,com.fasterxml.jackson.core:*,com.fasterxml.jackson.dataformat:*,com.fasterxml.jackson.datatype:*,org.apache.commons:commons-math3,org.graalvm.js:*,org.graalvm.polyglot:js,org.graalvm.regex:*,org.graalvm.shadowed:icu4j,org.graalvm.truffle:*,org.jline:*,org.xerial.snappy:*pom.xmlexclusions on every engine module (artifact ids;*= whole group).symtabsection in the shipped librarylibarcadedb.soIn short: the build sets the optimization level (
-O3) and the CPU baseline, no garbage collector and no heap option; the only heap setting is the run-time-XX:MaximumHeapSizePercent=50that the Python loader adds. The image contains the full server stack (the Bolt, GraphQL, MCP, PostgreSQL and Redis wire modules, the console and the integration module), so most of.textis code that an embeddedimport arcadedbdoes not run.Candidate changes, as hypotheses to measure, not findings
madvise(MADV_DONTNEED,MADV_COLDorMADV_PAGEOUT) on the library's file-backed mappings aftercreate_databaseor when the last database closes. The read-only mappings (r-xpandr--p) hold 41.8 MiB of the 48.2 MiB resident, and those pages are clean, so an idle process could shrink by up to about that much. Only the read-only mappings are safe forMADV_DONTNEED: on the writablerw-pimage-heap mappings it would discard pages the engine has modified. It needs no rebuild (it can live in the Python loader). Trade-off: the next call takes minor page faults again, and it does nothing for the peak while a query runs. To measure: resident size after close, and the latency of the first query afterwards.-Osinstead of-O3:.textis the larger resident part (26.6 of 48.2 MiB), and apom.xmlcomment records a timed comparison for-O3("warm geomean 0.736 at 1.62x the library size", referenced as Native image: -O3 is faster than -O2 on GraalVM CE (warm geomean 0.736), at 1.62x the library size #159), which I read as faster code at 1.62 times the size. Trade-off: going back to-Osgives up that speed for a smaller image; whether the resident part shrinks as much as the file is unknown. To measure: one build each, the same workload, and the existing timed A/B.arcadedb-bolt,-graphql,-mcp,-postgresw,-redisw, and possibly-server,-console,-integration) out of a second image. Trade-off: two images to build and test, and the server features ofarcadedb.serverwould need the full one. Resident pages count only what is touched, so the gain on resident memory could be much smaller than on disk; this needs a build to find out.ARCADEDB_NATIVE_ISOLATE_ARGS(for example-XX:MaximumYoungGenerationSizePercent, or the-Xmx64mof the issue, which gave 3.9 MiB less anonymous memory in my PrintGC run). No rebuild needed. Trade-off: more frequent collections and lower throughput in bulk loads, and a hard heap cap that can fail a large import. To measure: anonymous memory and load throughput over a range of values, on the same workload and on a bulk load..svm_heap, 21.1 MiB resident, 49.6 MiB on disk) by moving the build-time initialization of large static state to run time (--initialize-at-run-timefor selected packages), guided by a per-class breakdown of the image heap. I have not checked which diagnostic flags GraalVM CE 25.4 offers for that breakdown. Trade-off: slower start and slower first use, and the risk of initialization order bugs at run time. To measure: build with the candidates, then repeat the section table above.What is still unknown
/proc/kpageflagsor idle page tracking (root), or a run on a kernel with a smaller fault-around window.26.11.1.post2has about 4.6 MiB more of the library resident than26.11.1when both carry engine26.11.1and the library has almost the same size.madviseand isolate-argument ideas were not tried either.I can attach the probe script, the report script and the raw JSON lines if that is useful.