Back to Memory Management & Memory Mapping

Memory Management & Memory Mapping

Garbage Collection & Reference Counting

Skip to 2 practice questions

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:

  1. Reference counting decides when a q object is no longer needed.
  2. q’s allocator decides how freed memory is stored and reused inside the process.
  3. .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:a

This 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!a

When 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 namespace

Temporary 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:a

At this point, a and b share the same vector.

If you then amend b:

b[0]:42

q 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.

1. Create a:til 10000000 a 0 1 2 3 … 80 MB of data reference count: 1 2. Share b:a a b 0 1 2 3 … the same 80 MB reference count: 2 nothing copied 3. Change b b[0]:42 a 0 1 2 3 … count: 1 b 42 1 2 3 … count: 1 a whole new 80 MB copy to change one value q only copies when it must: changing a shared object would also change it for everyone else using it. When a count falls to 0, q frees the object at once. There is no background garbage collector.
Assignment shares; changing a shared object copies it.

This can surprise people:

a:til 10000000
b:a

.Q.w[]`used
b[0]:42
.Q.w[]`used

The 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 OS

This 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:

FieldMeaning
usedBytes currently in active use by q objects
heapBytes reserved by q’s allocator
peakLargest heap size seen since process start
wmaxWorkspace limit set by -w; 0 means no q workspace limit
mmapMemory currently used for memory-mapped files
mphyPhysical memory available on the machine
symsNumber of distinct interned symbols
symwMemory used by the interned symbol pool

Example:

q).Q.w[]`used`heap
371552 67108864

Here 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:

  • used answers: “How much of q’s heap is currently occupied by live q objects?”
  • heap answers: “How much memory has q reserved for its allocator?”

Freeing an object normally reduces used.

It may or may not reduce heap.

RSS: RAM the OS has given the q process what top, ps and htop show (the RES column) heap: memory q has reserved .Q.w[]`heap used live q objects (your tables, lists, …) .Q.w[]`used free blocks kept for reuse free blocks kept for reuse mapped files .Q.w[]`mmap pages of on-disk columns that a query has touched not part of q's heap: the OS can drop them and re-read them later the OS .Q.gc[] hands back free blocks of 64 MB or more
How the numbers nest. used is part of heap; heap and touched mapped pages together make up most of what the OS reports as RSS.

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`GOOG

But many unique symbols can grow the symbol pool:

`$string til 1000000

The syms and symw fields in .Q.w[] help you see this cost.

.Q.w[]`syms`symw

A 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 bytes

That 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 16560

til 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:

  1. q looks in the freelist for the required size category.
  2. If a suitable free block already exists, q reuses it.
  3. If not, q may split a larger free block into smaller blocks.
  4. If no suitable free block exists, q requests a new 64MB slab from the system.
  5. 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 mode

The equivalent command-line option is:

q -g 0    # deferred mode
q -g 1    # immediate mode

The default is deferred mode:

\g 0

Changing 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:

'wsfull

Deferred 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 201326592

The 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 201326592

The 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 67108864

Here 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 134217728

The 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[]
0

Nothing 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 67108864

After 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:

  1. The object is still referenced.
  2. The freed memory is fragmented.
  3. There is no unused block of at least 64MB.
  4. One live object remains inside an otherwise free slab.
  5. 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.

Why .Q.gc[] can free less than you expect Two 64 MB slabs after a large delete. Each square is a block inside the slab. Slab 1 one live object stays in q's heap it is almost empty, but not completely Slab 2 .Q.gc[] can merge it back into one 64 MB block and return it to the OS Lots of small objects freed at different times (nested data, strings, heavy grouping) tend to leave slabs like slab 1: .Q.w[]`used falls, because the memory is free inside q, but .Q.w[]`heap stays high, because q can't hand it back.
One small live object is enough to keep a whole 64 MB slab in q's heap.

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 100000

This 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[]
0

That 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!v

Serializing 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.

  • used is q’s live object memory.
  • heap is 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 4000

This sets a workspace limit of 4000MB.

Inside q, the limit appears as wmax in .Q.w[].

.Q.w[]`wmax

A 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:

DomainMeaning
0Regular anonymous memory, used for normal allocations by default
1Filesystem-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`heap

If 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]:42

The 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 201326592

Why 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]:42

Assume b was created using:

a:til 10000000
b:a

Answer

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