Skip to content

eliminate stack overflow #1639

@andrewrk

Description

@andrewrk

This is a proposal to solve #157.

First we must introduce a new kind of value, one that is neither comptime nor runtime.
It is a value that is known at compile-time, but only after all semantic analysis is complete,
and so it cannot participate in comptime code. For the purposes of this proposal,
this kind of value is called a "post-comptime" value. An actual comptime value can be used
where a post-comptime value is expected. Simple arithmetic such as +, -, and * can be
used on post-comptime values, to produce another post-comptime value. There also will be
some way to do a max() operation on post-comptime values.

External Functions

extern function declarations have a default stack size requirement,
which is dependent on the target. For example, on x86_64 Linux,
the default is 8388608 bytes (8 MiB). This matches the default stack
size on Linux for executables as well as default pthread stack sizes.
Zig treats every extern function as if it needs at least this much
stack space.

When an external function is known to require a larger or smaller amount
of stack space, it can be annotated:

extern<912> fn foo()void;

Here, foo is known to require only 912 bytes of stack space. In this example,
we provided a comptime value, but the expression can be a post-comptime value.

Function Pointers

One does not simply call a function pointer.

One must use @ptrCall(worst_case_stack_size, function, ...), where
worst_case_stack_size is a post-comptime value.

This inserts a runtime safety check - once compilation completes,
all function stack size requirements are known, and inserted into
the binary. A function pointer call checks if the provided
worst_case_stack_size value is correct.

This ensures that during semantic analysis, the compiler can rely on
this value when calculating worst case stack size.

Most APIs will want to accept comptime functions rather than function
pointers, as this avoids @ptrCall. For example, std.mem.Allocator,
std.io.InStream, std.io.OutStream, and std.rand.Random.

Recursion

Recursion, whether direct or indirect will be solved with the coroutines rewrite.
See #1260.

Call graph analysis detects loops:

fn fib(x: i32) i32 {
    if (x <= 1) return x;
    return fib(x - 1) + fib(x - 2); // error: unbounded stack growth
}

This will be fixed with coroutines:

async fn fib(allocator: *Allocator, x: i32) !i32 {
    if (x <= 1) return x;
    const frame = try allocator.create(@Frame(fib));
    defer allocator.destroy(frame);
    frame.* = async fib(x - 1);
    const value1 = await frame.*;
    frame.* = async fib(x - 2);
    const value2 = await frame.*;
    return value1 + value2;
}

Calls to this function grow heap memory, not stack memory. The function can
return error.OutOfMemory. This is a leaf node in the call graph analysis;
it does not store any information on the stack.

Annotating Additional Stack Usage (Inline Assembly)

In some cases, it may be necessary to annotate that more stack is used, even
when it does not come from a function call. One such example is inline
assembly that directly accesses the stack.

For these cases there will be a function @declareStackUsage(byte_count), where
byte_count is a post-comptime value.

Because Zig is not able to trace how this builtin call moves with LLVM's function
inlining optimization passes, the value is globally added to the root node of
the call graph.

Threads

Currently in std.os.spawnThread, Zig allocates a hard coded 8 MiB per thread.

With this proposal, it will use a new builtin function @stackSize(function).
This builtin returns a post-comptime number of bytes of the worst case stack
size required to call function.

Main Function

Zig knows the start symbol for the target. This is how it avoids including
std/special/bootstrap.zig when a program manually exports the start symbol.

During semantic analysis, Zig uses @stackSize(start_function) internally
to determine stack size requirements, and passes this to the linker.

Exported Functions

Zig internally runs @stackSize on all exported functions, in order to
run the call graph analysis and emit compile errors for recursion, etc.
The computed stack size requirements are included in generated .h files,
in comments, since C has no way to annotate stack size requirements for
functions.

Metadata

Metadata

Assignees

No one assigned

    Labels

    proposalThis issue suggests modifications. If it also has the "accepted" label then it is planned.

    Type

    No type

    Projects

    Status

    To do

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions