Skip to content

How to optimize q code for performance

This page explains how to make q code faster: use vector operations and specialized primitives, make queries use the data layout, avoid allocation and I/O, parallelize enough work, and cache repeated results.

Measure before and after each change with the same baseline workload. Optimize in this order:

  1. Vectorize and use the appropriate built-in operation.
  2. Make qSQL use the schema.
  3. Reduce allocation, copying, and I/O.
  4. Parallelize only when enough independent work remains.
  5. Cache expensive work with a clear invalidation rule.

If the query reads unnecessary partitions or scattered rows, fix the data layout first. A local code optimization cannot recover avoidable storage work.

Vectorize

q's built-in operations apply to whole lists in compiled loops. Code that works on a simple vector avoids invoking the q interpreter once per item and gives the runtime the best opportunity to use CPU caches and secondary threads.

Use built-in functions

Use the most specific built-in function or iterator for the operation. It usually avoids intermediate data structures and uses an implementation optimized for the q type system. For example, in implements membership directly; building the same test from Match and two iterators compares every pair:

q)a: 1000?100000; b: 100000?100000
q)(a in b) ~ any a ~\:/: b
1b

Compare the two with \t a in b and \t any a ~\:/: b on your host.

Built-ins can also remove a conversion. Tok parses dates with hyphens, and like accepts symbols directly:

q)"D"$"2026-04-01"
2026.04.01
q)s: 100000?`3
q)(where s like "aa*") ~ where string[s] like "aa*"
1b

Use specialized joins and aggregations as well. For example, use an as-of join for time-ordered lookups instead of writing a row-by-row search, and satisfy its key and sort requirements. The wrong column order or unsorted time column can turn the intended lookup into much more work.

Avoid explicit loops

A loop that tests one item at a time is rarely the right search in q:

fLoop: {[x]
  c: count x; i: 0; r: ();
  while[i < c; if[9990 < x i; r,: i]; i+: 1];
  r
  }
fVec: {[x] where 9990 < x}
q)l: 1000000?10000f
q)(fLoop l) ~ fVec l
1b

fVec compares the whole vector in one operation. An early-exit search can be an exception: a loop that returns after a match near the start can do less work than a vector expression that tests every item. See control execution for iterators and the cases where explicit control flow is appropriate.

Do not iterate atomic functions

An atomic function, such as +, already applies to every item of a list. Applying it with each invokes the function and constructs a result once per item:

q)l: 1000000?10000f
q)(last system "ts 3+l") < last system "ts (3+) each l"
1b

Keep data in simple typed vectors so these operations stay vectorized. See choose types deliberately for numeric width, symbols, and nested data.

Make qSQL use the schema

q evaluates constraints in the order you write them. Follow the complete qSQL performance guidelines; the essential rules are:

  • On a partitioned table, constrain the unmodified partition column first.
  • Put a constraint that can use an attribute next, with the unmodified column on the left.
  • Order the remaining constraints by how many rows they eliminate.
  • Select only the columns that the result needs.
  • When grouping a partitioned table, put the virtual partition column first in the by phrase.

For example:

select vwap: size wavg price by date, sym from trade
  where date within 2026.04.06 2026.04.10, sym in `AAPL`MSFT

Keep recognized aggregate functions visible to qSQL. Functions such as count, sum, avg, min, max, and wavg receive special treatment across partitions. Wrapping one inside a lambda can hide it from q and leave the programmer responsible for combining partial results correctly. See special functions in qSQL.

With secondary threads, a query on a partitioned table can process partitions in parallel. q also applies map-reduce to supported aggregations: it computes partial results per partition and combines them rather than materializing all matching rows first. In these two equivalent queries, the first exposes wavg directly; the second builds a table of all matching rows before the outer aggregation:

q)t1: select vwap: size wavg price by sym from trade where date within 2026.04.06 2026.04.10
q)t2: select vwap: (sum size*price) % sum size by sym from select sym, size, price from trade where date within 2026.04.06 2026.04.10
q)t1 ~ t2
1b

Measure rewrites with the real distribution of groups. A manual per-symbol query with peach can help a few expensive, uneven groups, but it also creates intermediate tables and more tasks. Prefer the direct query until a benchmark shows a material gain.

Reduce allocation, copying, and I/O

The fastest byte is one that q does not allocate, copy, format, serialize, or send.

Filter and aggregate before materializing

Push selective constraints into the query that reads the table, and aggregate on the process that owns the data. Do not fetch a wide raw table across IPC and discard most of it on the client. When a list or dictionary is sufficient, exec can avoid constructing a table result; when only a count or aggregate is needed, return that scalar.

Prefer a built-in aggregate that expresses the operation directly. For example, size wavg price lets q use its implementation and partition handling instead of materializing size*price for a client-side calculation.

Update in place where safe

q uses reference counts and copies a value when an update cannot safely reuse it. v: v,1 constructs a joined list and assigns it; v,: 1 amends the named list and can reuse its storage:

q)v: til 1000000
q)(last system "ts v,:1") < last system "ts v:v,1"
1b

The saving depends on aliases, type promotion, capacity, and the operation. Treat an in-place form as an opportunity to measure, not a guarantee that no allocation occurs. For global tables, insert or an amend by name can append columns without first constructing a second whole table.

Batch work

Every IPC message, function call, and insert has a fixed cost. Insert or publish vectors of rows instead of repeatedly sending one row when the latency budget permits. A tickerplant that publishes on a timer trades a bounded amount of latency for much higher throughput; measure update size and interval with tick profiling.

Batch file work as well. Opening, mapping, and closing many tiny files can dominate the data transfer. Avoid creating overly fine partitions.

A single set writes the columns of a splayed table sequentially. If end-of-day writing is the measured bottleneck, independent columns or partitions can be written to separate files in parallel; follow the ownership, enumeration, compression, and atomicity constraints in splayed-table parallelism.

Logging durability is also part of ingestion performance. Synchronous -L waits for a storage write where -l can rely on buffering. Preserve the required recovery guarantee, and use batching and suitable log storage instead of weakening durability only to improve a benchmark.

Keep output out of hot paths

Console display, logging, string formatting, and synchronous IPC can dominate a fast calculation. Remove show, 0N!, verbose tracing, and per-row formatting from production hot paths, or sample them at a controlled rate. Measure observability code under load: an asynchronous logger can still queue data faster than its consumer writes it.

Broadcast one serialization

To send the same asynchronous message to several handles, -25! serializes it once to the lowest capability of the handles:

-25!(handles; msg)

The call queues the messages; it does not confirm delivery. See broadcast a message for flushing and delivery semantics.

Parallelize

By default, a q process executes incoming work on its main thread. Start secondary threads with -s:

q -s 8

\s shows or changes the active count up to the startup maximum. Begin at or below the .z.c physical-core count and leave capacity for other processes. An I/O-bound workload can sometimes benefit from more tasks than cores, while a memory-bandwidth-bound workload often stops scaling well before every core is used.

Thread scaling

Parallel speedup is not linear:

  • Dispatch and combination have a fixed cost. As task count rises, each task does less useful work.
  • Multithreaded primitives have a minimum chunk size, currently in the order of 105 vector items depending on the primitive. Small vectors remain serial.
  • Threads share memory bandwidth, last-level cache, and storage. More threads do not help after one of those is saturated.
  • Serial and uneven work remains. The parallel step takes at least as long as its largest indivisible task, and the main-thread work around it does not get faster.

Measure several thread counts in one process. \s 0 disables secondary threads, and \s N restores up to the maximum set by -s:

v: 10000000?100f
scale: {[n; e] system "s ", string n; system "t:10 ", e}
threads: 0 1 2 4 8
threads!scale[; "sum v"] each threads

Choose the smallest count at which the target stops improving. Also test concurrent clients: the thread count that minimizes one query's latency can reduce total server throughput.

Multithreaded primitives

Many primitives, including arithmetic, lookups, aggregates, where, and parts of select, divide a sufficiently large vector among secondary threads automatically. Supported operations and the part that parallelizes are listed in the reference; do not assume every function in a query uses every thread.

Call a multithreaded primitive on the largest useful vector. Manually cutting the same vector or wrapping the primitive in peach can add split, scheduling, and join costs without exposing more parallelism.

Peach

peach applies a unary function to each item of a list, like each, but makes the calls available to secondary tasks:

q)f: {[x] sum sqrt til x}
q)(f each 8#1000000) ~ f peach 8#1000000
1b

Use it when calls are independent and each call does enough work to pay for scheduling. It is useful for separate partitions or files when no two tasks access the same file concurrently. A cheap function is usually slower with peach than with each, and both can be slower than one vector call.

peach accepts a unary value. Project a higher-rank function with Apply:

q).[{x+y};] peach (1 2; 3 4)
3 7

Work stealing

Since kdb+ 4.1, secondary tasks use a work-stealing scheduler. A task that finishes can take queued work, which helps when partitions or groups have different sizes. Nested peach and multithreaded primitives inside peach can also use idle tasks.

Parallelize the outermost level with enough substantial items. It usually creates fewer, larger tasks, while nested primitives can use capacity left idle. Work stealing cannot split a task that is already running, so one exceptionally large item can still determine completion time.

Before 4.1, parallelism was single-level

Earlier versions preallocated chunks and ran nested parallel work serially. See peach and parallel-processing enhancements.

Limits of peach

  • Secondary threads cannot update global variables. Return values and update globals on the main thread. Opening sockets and system commands that change global state are also restricted; see blocked operations.
  • Each secondary thread has a heap. Parallel calls can hold several working sets simultaneously, so peach can use much more resident memory than each.
  • \ts and .Q.w are thread-local. They do not report the allocations of all secondary threads. Measure the process resident set externally.
  • File access must be independent. Different tasks can use different files, but must not read or write the same compressed file concurrently.
q)g: 0
q){g:: x} peach 1 2
'noupdate: `. `g

Parallel on cut

.Q.fc cuts a vector into a small number of pieces and applies a unary atomic function to the pieces in parallel. It can suit a vector function that does not already parallelize, because it schedules once per piece rather than once per item.

Compare .Q.fc with a direct vector call and with peach. If the function consists of multithreaded primitives, the direct call is usually the better starting point because the primitives already divide the vectors without an extra cut and join.

Secondary processes

A negative -s configures peach to use q processes through handles returned by .z.pd. Processes can run on separate NUMA nodes or hosts and have independent heaps, but arguments and results must be serialized over IPC. This can help work that needs isolation or more than one host; it can hurt fine-grained work whose data transfer exceeds its computation.

Concurrent queries

Secondary threads provide within-query parallelism; they do not make the default IPC loop execute unrelated client requests concurrently. A long request can delay every request queued behind it.

For concurrent read workloads, run several read-only replicas behind a gateway or load balancer, and size the secondary threads of each replica so their combined demand does not oversubscribe the host.

Multithreaded input mode, enabled with a negative port, is a specialized alternative for static in-memory data and a controlled number of clients. It is not intended for an on-disk database, a gateway, or mutable data. Each connection has a thread and a substantial independent memory requirement, so use it only when those constraints match the service.

Cache

CPU caches and the operating system page cache work automatically. Improve their effectiveness by keeping data compact and reading only the rows and columns required.

Apply a function to unique values

When a vector repeats a small domain, .Q.fu applies a function once per distinct value and maps the results back:

q)vec: 1000000?30
q)f: {exp x*x}
q)(f vec) ~ .Q.fu[f; vec]
1b

Finding and mapping the distinct values has a cost, so .Q.fu loses when most items are distinct. It is correct only when each result depends on that item alone.

Cache derived results

Precompute an expensive, frequently repeated aggregate when the source changes less often than the query runs. For example, store daily aggregates beside immutable historical data instead of scanning ticks for every request.

Define the cache key, maximum size, owner, and invalidation event before adding it. Historical data can still receive corrections; a cache without correction and end-of-day invalidation trades correctness for speed. A gateway can share results across clients, but its cache lookup, memory, and synchronous downstream calls must not make the gateway the new bottleneck.

Keep partitions mapped

By default, a query maps the partition column files it needs and releases them afterwards. .Q.MAP retains the mappings and file handles, reducing repeated file-system calls:

q).Q.MAP[]

Use it only after measuring a workload where mapping overhead matters. It has important limits:

  • it does not work with linked columns or virtual partition columns;
  • it is not recommended for compressed files, because decompressed mappings remain in memory; and
  • it can require much higher open-file and vm.max_map_count limits.

Next steps