Memory Management & Memory Mapping
Garbage Collection & Reference Counting
q’s “garbage collection” is not the tracing garbage collection you may know from Java, Python, C#, or Go.
There is no background collector periodically scanning memory to find unreachable objects. Instead, q objects use reference counting.
When an object no longer has any references, q no longer needs to preserve that object. Its memory becomes eligible to be returned to q’s own allocator heap, where it can be reused by later allocations.
That gives us three separate ideas:
- Reference counting decides when a q object is no longer needed.
- q’s allocator decides how freed memory is stored and reused inside the process.
.Q.gc[]tries to coalesce unused allocator memory and return suitably large blocks back to the operating system.
This distinction is the key to understanding q memory behaviour.
.Q.gc[] is not usually needed to make an unused q object disappear. The object becomes unused when its reference count reaches zero. .Q.gc[] is about returning memory from q’s heap to the OS.
Reference counting
q tracks how many references point to an object.
For example:
a:til 10
b:aThis does not copy the vector. Both a and b refer to the same underlying object.
You can inspect an object’s reference count with:
-16!aWhen a reference is removed, the reference count falls. When the count reaches zero, the object’s memory can be returned to q’s allocator heap.
References can disappear in several ways:
a:1 / old value of a is no longer referenced by a
delete a from `. / remove global a from the root namespaceTemporary objects and local variables can also become unreferenced when an expression or function finishes.
Copy-on-write
Because q objects can be shared, assignment does not necessarily mean copying.
a:til 10000000
b:aAt this point, a and b share the same vector.
If you then amend b:
b[0]:42q cannot modify the shared vector in place, because a still refers to the original value. q therefore performs copy-on-write.
For a simple vector, this means q allocates a new vector for b. It is not just the changed element that is copied. The vector as a whole is copied.
This can surprise people:
a:til 10000000
b:a
.Q.w[]`used
b[0]:42
.Q.w[]`usedThe amend to b can cause a large increase in memory use because it has forced a full copy of the shared vector.
Useful diagnostics:
-16!a / reference count
-22!a / serialized size estimate-22! estimates serialized size. It is useful, but it is not the same as the real heap footprint of an object. Actual in-memory use also includes type information, allocator overhead, alignment, and other housekeeping.
What happens when memory is freed?
When an object is no longer referenced, its memory is returned to q’s memory management system.
However, that does not necessarily mean the operating system immediately sees the process shrink.
q manages its own heap. Freed memory usually goes back to q’s heap first, where it can be reused by the same process.
A useful mental model is:
q object becomes unreferenced
-> reference count reaches zero
-> memory is returned to q's thread-local heap
-> q may reuse it later
-> .Q.gc[] may later return large unused blocks to the OSThis is why a process can free a large object and still appear large from the operating system’s point of view.
.Q.w[]: q memory statistics
.Q.w[], or the shorthand \w, shows q’s memory statistics.
.Q.w[]Common fields are:
| Field | Meaning |
|---|---|
used | Bytes currently in active use by q objects |
heap | Bytes reserved by q’s allocator |
peak | Largest heap size seen since process start |
wmax | Workspace limit set by -w; 0 means no q workspace limit |
mmap | Memory currently used for memory-mapped files |
mphy | Physical memory available on the machine |
syms | Number of distinct interned symbols |
symw | Memory used by the interned symbol pool |
Example:
q).Q.w[]`used`heap
371552 67108864Here used is small, but heap is 64MB. q has reserved 64MB for its heap, even though only a small part is actively used.
The distinction between used and heap is central:
usedanswers: “How much of q’s heap is currently occupied by live q objects?”heapanswers: “How much memory has q reserved for its allocator?”
Freeing an object normally reduces used.
It may or may not reduce heap.
Symbols and symw
Symbols have their own important memory behaviour.
Every distinct symbol a q process sees is interned. That means q stores the symbol once in a process-wide symbol pool, and later occurrences of the same symbol reuse that interned value.
This is efficient when the same symbols repeat many times.
It can become expensive when a process sees a very large number of distinct symbols.
For example, repeated values like these are cheap:
1000000?`AAPL`MSFT`GOOGBut many unique symbols can grow the symbol pool:
`$string til 1000000The syms and symw fields in .Q.w[] help you see this cost.
.Q.w[]`syms`symwA high symw can be easy to overlook because symbol memory is not tied to one obvious table or variable.
q’s allocator: size classes, slabs, and freelists
q’s allocator groups objects into power-of-two size categories, from small objects such as atoms up to 64MB.
For example, a long is 8 bytes. A vector of 800 longs needs roughly:
800 * 8 = 6400 bytesThat does not fit into a 4096-byte category, so it uses the next suitable power-of-two category, 8192 bytes.
Likewise:
q)\ts til 800
0 8368
q)\ts til 1000
0 8368
q)\ts til 1200
0 16560til 800 and til 1000 both fit into the same allocator category, so they use roughly the same amount of memory. til 1200 needs the next larger category.
The reported size is larger than the raw vector payload because q also stores type information and other bookkeeping.
When q needs memory, the allocator broadly works like this:
- q looks in the freelist for the required size category.
- If a suitable free block already exists, q reuses it.
- If not, q may split a larger free block into smaller blocks.
- If no suitable free block exists, q requests a new 64MB slab from the system.
- Allocations larger than 64MB are requested directly from the OS.
When an object is deallocated, its memory block is returned to the freelist for its size category.
This design is fast because many future allocations can be satisfied without asking the OS for more memory.
The trade-off is that the q process may keep memory reserved even after objects have been freed.
Garbage-collection mode: \g
q has a garbage-collection mode controlled by \g.
\g / show current garbage-collection mode
\g 0 / deferred mode
\g 1 / immediate modeThe equivalent command-line option is:
q -g 0 # deferred mode
q -g 1 # immediate modeThe default is deferred mode:
\g 0Changing the mode using \g mode automatically calls .Q.gc[] after setting the new mode.
Deferred mode: \g 0
Deferred mode is the default.
In deferred mode, when an object is freed, its memory is returned to q’s thread-local heap. The memory is then available for q to reuse, but it is not necessarily returned to the operating system immediately.
Memory can later be returned to the OS when:
.Q.gc[]is called manually, or- q is denied additional address space, calls
.Q.gc[], and retries the allocation.
If the retry still fails, the request exits with:
'wsfullDeferred mode usually has better performance because q avoids returning memory to the OS inline every time memory is freed.
Example:
q).Q.w[]`used`heap
371552 67108864
q)a:til 10000000
q).Q.w[]`used`heap
134589328 201326592
q)a:1
q).Q.w[]`used`heap
371616 201326592The large vector is no longer referenced after a:1.
used falls because the old vector is no longer live.
heap stays high because q is keeping that memory for reuse.
If we allocate a similar object again:
q)a:til 10000000
q).Q.w[]`used`heap
134589328 201326592The heap does not need to grow, because q can reuse memory it already has.
To try to return unused heap memory to the OS:
q)a:1
q).Q.gc[]
134217728
q).Q.w[]`used`heap
371616 67108864.Q.gc[] returns the number of bytes returned to the operating system.
Immediate mode: \g 1
Immediate mode changes what happens when large objects are freed.
In immediate mode, when memory is returned to q’s thread-local heap, if the freed object is at least 64MB, q returns that memory to the OS immediately instead of keeping it in the heap.
Example:
q)\g 1
q).Q.w[]`used`heap
371648 67108864
q)a:til 10000000
q).Q.w[]`used`heap
134589424 201326592
q)a:1
q).Q.w[]`used`heap
371712 67108864Here the heap shrinks after a:1, because the freed object was large enough to return to the OS immediately.
The benefit is easier memory dimensioning: the OS sees large frees sooner.
The cost is performance: returning memory to the OS does work immediately, instead of deferring it.
Immediate mode is useful when predictable process memory size matters more than allocation/free performance.
One large free is different from many small frees
Immediate mode does not mean that any total of 64MB can be returned immediately.
The threshold applies to the individual freed allocation.
For example, freeing one 80MB object in immediate mode can return memory immediately.
But freeing ten 8MB objects does not do the same thing, even though the total is also 80MB.
Those smaller freed blocks are returned to q’s heap. They may be reused later, or .Q.gc[] may later coalesce them into a large enough block to return to the OS.
Example pattern:
q)\g 1
q)v:`a`b`c`d`e`f`g`h`i`j
q){set[x;til 1000000]} each v
q).Q.w[]`used`heap
84258096 134217728
q)![`.;();0b;v]
q).Q.w[]`used`heap
371824 134217728The variables have been deleted, so used falls.
But heap stays high because the freed objects were individually below the immediate-return threshold.
Now run:
q).Q.gc[]
67108864.Q.gc[] may be able to coalesce the smaller free blocks and return a larger block to the OS.
This is one of the main reasons .Q.gc[] still matters even when immediate mode exists.
What .Q.gc[] does
.Q.gc[] runs q’s garbage-collection operation:
.Q.gc[]It returns the number of bytes returned to the operating system.
The important phrase is returned to the operating system.
.Q.gc[] attempts to coalesce unused pieces of the heap back into their original allocation units. Any unused unit of at least 64MB can be returned to the OS.
Example:
q)a:til 10000000
q).Q.gc[]
0Nothing is returned, because a is still referenced.
Now delete it:
q)delete a from `.
`.
q).Q.w[]`used`heap
371376 201326592
q).Q.gc[]
134217728
q).Q.w[]`used`heap
371376 67108864After a is deleted, the object is no longer referenced. used has already fallen. .Q.gc[] then returns unused heap memory to the OS, reducing heap.
Why .Q.gc[] can return 0
.Q.gc[] can only return memory if it can find or rebuild a sufficiently large unused block.
A q process can have lots of free memory inside its heap but still have no single unused block of at least 64MB that can be returned to the OS.
Common reasons .Q.gc[] returns 0 include:
- The object is still referenced.
- The freed memory is fragmented.
- There is no unused block of at least 64MB.
- One live object remains inside an otherwise free slab.
- The memory was freed, but it is in blocks that cannot currently be coalesced into a returnable unit.
This is why used can fall dramatically while heap remains high.
Fragmentation
Fragmentation means q has free memory inside its heap, but that memory is not arranged in a form that can be returned to the OS.
There are two important cases.
1. Split slabs
Suppose q needs a 32MB allocation. It may request a 64MB slab from the OS and split it into two 32MB blocks.
Later, both 32MB blocks may be freed.
Before coalescing, q has two free 32MB blocks, not one free 64MB block. If q now needs a 64MB block, it may need to request more memory from the OS.
When .Q.gc[] runs, it attempts to coalesce those pieces back together.
2. Leftover objects
Suppose a 64MB slab contains many small objects.
Most of them are freed, but one small object remains live.
That slab still cannot be returned to the OS, because part of it is still in use.
This is especially relevant for nested data, variable-length char vectors, and workloads involving lots of grouping.
Example: fragmented nested data
Consider a nested list where each row contains a short char vector and a long char vector (this example uses about 1.6GB of memory):
v:{(10#"a";10000#"b")}each til 100000This creates many separate nested allocations.
Now keep only the first element from each row:
v:v[;0]The long char vectors are no longer part of v, so used can fall significantly.
But the freed memory may be scattered throughout the heap. There may be no contiguous unused block of at least 64MB.
So this can happen:
q).Q.gc[]
0That does not mean the memory is still actively used.
It means .Q.gc[] could not find or rebuild a large enough unused block to return to the OS.
A diagnostic compaction trick is to serialize and deserialize the surviving object:
v:-8!v
.Q.gc[]
v:-9!vSerializing v creates a compact serialized representation and releases the fragmented original structure. .Q.gc[] may then be able to return large unused blocks to the OS. Deserializing rebuilds the data in a cleaner layout.
This is useful for understanding fragmentation. It should not automatically be treated as routine production code.
.Q.gc[0]: faster partial garbage collection
Since kdb+ 4.1t 2022.07.01, q also supports:
.Q.gc[0]This performs a faster subset of .Q.gc[].
It returns unused blocks that are already at least 64MB, but it skips the more expensive coalescing work.
That means:
.Q.gc[0]can be faster than.Q.gc[];- it may return less memory;
- it is less helpful when free memory is fragmented into smaller blocks.
Use .Q.gc[0] when you want a cheaper attempt to return obviously free large blocks.
Use .Q.gc[] when you want q to spend more effort trying to coalesce smaller free blocks.
Secondary threads
q’s heap is thread-local.
Each secondary thread has its own heap.
When .Q.gc[] is called from the main thread, q automatically invokes .Q.gc[] in each configured secondary thread as well.
When .Q.gc[] is called from a secondary thread, it affects only that secondary thread’s local heap.
This matters when diagnosing memory in multi-threaded q processes.
A main-thread .Q.gc[] is broader.
A secondary-thread .Q.gc[] is local to that thread.
RSS versus q heap
.Q.w[] reports q’s internal memory accounting.
The operating system has its own view of process memory, often seen through RSS.
Since kdb+ 3.3 (2015.08.23), on Linux, .Q.gc[] also releases the heap's unused pages from the process's resident memory (RSS).
That means .Q.gc[] can reduce both q’s internal heap accounting and the resident memory reported by the OS.
However, used, heap, and RSS are not the same thing.
usedis q’s live object memory.heapis memory reserved by q’s allocator.- RSS is the operating system’s view of resident physical pages.
They often move together, but not always.
Workspace limit: -w
The -w startup flag sets a q workspace limit.
The value is specified in megabytes:
q -w 4000This sets a workspace limit of 4000MB.
Inside q, the limit appears as wmax in .Q.w[].
.Q.w[]`wmaxA value of 0 means there is no q workspace limit.
Since kdb+ 4.0, -w limits the heap across threads for memory domain 0. In older versions, it was a per-thread heap limit.
If q tries to allocate beyond the workspace limit, the allocation can fail with:
'wsfull'wsfull can also occur if the OS denies q additional address space and q cannot recover enough memory after running .Q.gc[].
Memory-domain option: -m
The -m path startup flag is a specialised memory-domain feature.
It allows memory to be backed by a filesystem path, such as a DAX-enabled filesystem, as a non-persistent memory extension for kdb+.
Conceptually, this splits each thread’s heap into two memory domains:
| Domain | Meaning |
|---|---|
0 | Regular anonymous memory, used for normal allocations by default |
1 | Filesystem-backed memory |
This is separate from ordinary garbage collection.
It should not be treated as a normal fix for heap growth or fragmentation. It is a specialised configuration option for systems that deliberately use filesystem-backed memory.
Practical diagnostic guide
used is low but heap is high
This usually means q has freed objects, but is keeping the memory in its heap for reuse.
This is normal in deferred mode.
Try:
.Q.gc[]If q can find suitable unused blocks, heap may fall.
.Q.gc[] returns 0
This does not necessarily mean nothing was freed.
It may mean:
- the object is still referenced;
- the free memory is fragmented;
- there is no unused block of at least 64MB;
- one small live object is preventing a slab from being returned;
- the freed blocks are too small and cannot currently be coalesced.
Check both used and heap:
.Q.w[]`used`heapIf used has fallen but heap has not, q has freed live object memory but retained the heap.
Immediate mode did not shrink the heap
Immediate mode only returns memory immediately for sufficiently large individual frees.
Many smaller objects can add up to more than 64MB and still remain in q’s heap.
In that case, immediate and deferred mode behave similarly until .Q.gc[] coalesces memory.
Copy-on-write unexpectedly increased memory
This can happen when modifying a shared object.
a:til 10000000
b:a
b[0]:42The amend to b can force a full copy of the vector.
If memory jumps unexpectedly after a small update, check whether the object was shared.
Nested char vectors or grouping caused memory growth
Nested data and grouping-heavy workloads can create many smaller allocations.
That can fragment the heap.
The process may have plenty of free memory internally, but .Q.gc[] may be unable to return much of it to the OS.
Summary
q uses reference counting, not tracing garbage collection.
When an object no longer has references, q can return that object’s memory to its allocator heap.
That reduces used.
It does not necessarily reduce heap.
.Q.gc[] tries to coalesce unused pieces of the heap and return large unused blocks, at least 64MB, to the operating system.
Deferred mode, \g 0, keeps freed memory in q’s heap for reuse and is usually faster.
Immediate mode, \g 1, returns large freed objects to the OS immediately, but has extra overhead and does not help with many smaller frees.
Fragmentation explains why .Q.gc[] may return less memory than expected, especially with nested data, variable-length char vectors, or heavy grouping.
The most useful distinction is:
reference counting frees q objects;
.Q.gc[] returns allocator memory to the OS.Practice questions
Question 1
You run:
q)a:til 10000000
q).Q.w[]`used`heap
134589328 201326592
q)a:1
q).Q.w[]`used`heap
371616 201326592Why did used fall but heap stay the same?
Answer
The old vector is no longer referenced, so its memory is no longer counted as active object memory. That is why used falls.
But in deferred mode, q returns the freed memory to its own heap for reuse. It does not immediately return that memory to the OS. That is why heap stays the same.
Question 2
You run in immediate mode and free ten objects of roughly 8MB each. Their combined size is more than 64MB, but the heap does not shrink. Why?
Answer
Immediate mode applies to sufficiently large individual frees. Each object is around 8MB, so each one is below the immediate-return threshold.
The memory is returned to q’s heap. .Q.gc[] may later be able to coalesce those smaller freed blocks into a larger block that can be returned to the OS.
Question 3
.Q.gc[] returns 0, but .Q.w[] shows that used has dropped a lot. What does that suggest?
Answer
It suggests the memory is no longer actively used by q objects, but q could not return it to the OS.
A common reason is fragmentation. The freed memory may be scattered across the heap, with no contiguous unused block of at least 64MB available to return.
Question 4
Why can this line allocate a lot of memory?
b[0]:42Assume b was created using:
a:til 10000000
b:aAnswer
a and b initially share the same vector.
When b is amended, q cannot modify the shared vector in place because a still needs the original value. q performs copy-on-write and creates a new vector for b.
For a simple vector, that means copying the whole vector, not only the changed element.
Practice Questions
Test your understanding with 2 hands-on questions