Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Reforking steep #1492

Open
wants to merge 13 commits into
base: master
Choose a base branch
from
Open

Reforking steep #1492

wants to merge 13 commits into from

Conversation

pocke
Copy link
Contributor

@pocke pocke commented Feb 6, 2025

This PR adds an experimental feature, reforking Steep.

Background

This feature focuses on reducing memory usage.

Steep consumes too much memory because it launches as many processes as there are CPUs.
A single process uses a lot of memory. For example, in a middle-size Rails application, one Steep process consumes ~1.5 GB of memory.

About reforking

The Refork feature solves this problem. I borrowed this idea from Puma. https://github.com/puma/puma/blob/master/docs/fork_worker.md

It works like the following:

  1. steep langserver starts
    • It forks worker processes from the master process.
    • steep master
      ├─ steep interaction worker
      ├─ steep typecheck worker
      ├─ steep typecheck worker
      ├─ steep typecheck worker
      └─ steep typecheck worker
      
  2. It finishes the first type checking.
  3. After that, a worker (called "primary worker") forks workers again. And it kills old workers.
    • steep master
      ├─ steep interaction worker
      └─ steep typecheck worker
        ├─ steep typecheck worker
        ├─ steep typecheck worker
        └─ steep typecheck worker
      

In the first step, these workers share limited memories. The master process and worker processes share class objects, and so on. But they do not share objects for type checking.

In the third step, after reforking, these workers share more memories. Because the new workers forked from a worker. They share objects for type-checking also.

Implementation on Steep

I implemented this feature as --refork option of steep langserver command.

The reforking architecture is suitable for long-lived processes. So it's disabled on other commands such as steep check.

This option is disabled by default and marked as experimental.
I'd like to enable it by default in the future, but currently we need more investigation for the memory reduction.

Benchmarking

I benchmarked with this script. https://gist.github.com/pocke/ebce89b7341525b5c013cf3a6fa47721

It runs type checking, and execute free -h command to check memory usage.

I executed it on a docker container. In this test, the reforking feature reduce 1.9GB memory.

# Without reforking
               total        used        free      shared  buff/cache   available
Mem:            15Gi       3.4Gi        12Gi       2.2Mi       471Mi        12Gi
Swap:           16Gi       565Mi        16Gi
"initialize finished"
"typecheck finished"
"typecheck finished"
               total        used        free      shared  buff/cache   available
Mem:            15Gi        10Gi       5.0Gi       2.2Mi       441Mi       5.2Gi
Swap:           16Gi       564Mi        16Gi
USER         PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
root         138  0.0  0.0   4488  2896 pts/2    Ss+  14:16   0:00 bash
root          69  0.0  0.0   4616  2368 pts/1    Ss   14:14   0:00 bash
root         286  1.9  0.1 224940 29528 pts/1    Sl+  14:38   0:00  \_ /usr/local/bin/ruby tmp/steep_memory_benchmark2.rb
root         289 16.8  1.8 2605364 297032 pts/1  Sl+  14:38   0:08      \_ ruby /app/vendor/bundle/ruby/3.1.0/bin/steep langserver --log-output tmp/steep-log
root         293  4.5  1.5 364408 258548 pts/1   Sl   14:38   0:02      |   \_ ruby /app/vendor/bundle/ruby/3.1.0/bin/steep langserver --log-output tmp/steep-log
root         295 57.3  4.9 896116 810944 pts/1   Sl   14:38   0:27      |   \_ ruby /app/vendor/bundle/ruby/3.1.0/bin/steep langserver --log-output tmp/steep-log
root         297 59.6  4.9 949712 806788 pts/1   Sl   14:38   0:28      |   \_ ruby /app/vendor/bundle/ruby/3.1.0/bin/steep langserver --log-output tmp/steep-log
root         299 55.0  4.8 1012820 793220 pts/1  Sl   14:38   0:26      |   \_ ruby /app/vendor/bundle/ruby/3.1.0/bin/steep langserver --log-output tmp/steep-log
root         301 57.7  4.9 1087164 811024 pts/1  Sl   14:38   0:27      |   \_ ruby /app/vendor/bundle/ruby/3.1.0/bin/steep langserver --log-output tmp/steep-log
root         303 60.6  5.1 1166364 843536 pts/1  Sl   14:38   0:28      |   \_ ruby /app/vendor/bundle/ruby/3.1.0/bin/steep langserver --log-output tmp/steep-log
root         305 61.6  4.9 1221680 809856 pts/1  Sl   14:38   0:29      |   \_ ruby /app/vendor/bundle/ruby/3.1.0/bin/steep langserver --log-output tmp/steep-log
root         307 57.0  4.8 1289208 802452 pts/1  Sl   14:38   0:27      |   \_ ruby /app/vendor/bundle/ruby/3.1.0/bin/steep langserver --log-output tmp/steep-log
root         309 53.5  4.9 1353400 810316 pts/1  Sl   14:38   0:25      |   \_ ruby /app/vendor/bundle/ruby/3.1.0/bin/steep langserver --log-output tmp/steep-log
root         311 58.8  4.9 1359832 809460 pts/1  Sl   14:38   0:27      |   \_ ruby /app/vendor/bundle/ruby/3.1.0/bin/steep langserver --log-output tmp/steep-log
root         348  100  0.0   8592  4088 pts/1    R+   14:39   0:00      \_ ps auxf
root           1  0.0  0.0   4488   744 pts/0    Ss+  14:08   0:00 /bin/bash
# With reforking
               total        used        free      shared  buff/cache   available
Mem:            15Gi       3.7Gi        11Gi       2.2Mi       429Mi        11Gi
Swap:           16Gi       564Mi        16Gi
"initialize finished"
"typecheck finished"
"typecheck finished"
               total        used        free      shared  buff/cache   available
Mem:            15Gi       8.6Gi       6.9Gi       2.2Mi       440Mi       7.1Gi
Swap:           16Gi       564Mi        16Gi
USER         PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
root         138  0.0  0.0   4488  2896 pts/2    Ss   14:16   0:00 bash
root         350  1.5  0.1 224932 29284 pts/2    Sl+  14:39   0:00  \_ /usr/local/bin/ruby tmp/steep_memory_benchmark2.rb --refork
root         353 16.2  1.8 3742756 297608 pts/2  Sl+  14:39   0:07      \_ ruby /app/vendor/bundle/ruby/3.1.0/bin/steep langserver --log-output tmp/steep-log --refork
root         357  4.9  1.5 364432 258544 pts/2   Sl   14:39   0:02      |   \_ ruby /app/vendor/bundle/ruby/3.1.0/bin/steep langserver --log-output tmp/steep-log --refork
root         359 51.2  4.6 857888 765592 pts/2   Sl   14:39   0:24      |   \_ ruby /app/vendor/bundle/ruby/3.1.0/bin/steep langserver --log-output tmp/steep-log --refork
root         412 43.5  4.9 883056 807948 pts/2   Sl   14:40   0:08      |       \_ ruby /app/vendor/bundle/ruby/3.1.0/bin/steep langserver --log-output tmp/steep-log --refork
root         416 38.1  4.8 883056 793736 pts/2   Sl   14:40   0:07      |       \_ ruby /app/vendor/bundle/ruby/3.1.0/bin/steep langserver --log-output tmp/steep-log --refork
root         420 40.1  4.8 880608 801280 pts/2   Sl   14:40   0:07      |       \_ ruby /app/vendor/bundle/ruby/3.1.0/bin/steep langserver --log-output tmp/steep-log --refork
root         424 45.3  4.9 883056 809732 pts/2   Sl   14:40   0:08      |       \_ ruby /app/vendor/bundle/ruby/3.1.0/bin/steep langserver --log-output tmp/steep-log --refork
root         428 45.1  4.9 883056 808104 pts/2   Sl   14:40   0:08      |       \_ ruby /app/vendor/bundle/ruby/3.1.0/bin/steep langserver --log-output tmp/steep-log --refork
root         432 38.5  4.9 883056 805888 pts/2   Sl   14:40   0:07      |       \_ ruby /app/vendor/bundle/ruby/3.1.0/bin/steep langserver --log-output tmp/steep-log --refork
root         436 37.1  4.9 882016 806732 pts/2   Sl   14:40   0:06      |       \_ ruby /app/vendor/bundle/ruby/3.1.0/bin/steep langserver --log-output tmp/steep-log --refork
root         440 41.7  4.9 881808 809416 pts/2   Sl   14:40   0:07      |       \_ ruby /app/vendor/bundle/ruby/3.1.0/bin/steep langserver --log-output tmp/steep-log --refork
root         445  0.0  0.0   8592  4076 pts/2    R+   14:40   0:00      \_ ps auxf
root          69  0.0  0.0   4616  2368 pts/1    Ss+  14:14   0:00 bash
root           1  0.0  0.0   4488   744 pts/0    Ss+  14:08   0:00 /bin/bash

Implementation Details

How to share IO

Steep shares IO between the master and workers to read and write LSP messages. For ordinary workers, the IO is created by IO.pipe and shared with fork.
But we can't share the IO with fork for the reforked workers because the workers are not direct children of the master.

To share the IO, I use a UNIX socket created by socketpair(2).
UNIX socket allows you to send and receive IO. The master send IO for communication between the master and a grandchild worker to the primary worker via the socket, then the primary worker passes the IO with fork to the grandchild worker.

sequenceDiagram
  participant M as Master
  participant P as Primary Worker
  participant W as Grandchild Worker


  M->>M: Create socket with `socket_master, socket_worker = Socket.socketpair`
  M->>P: `fork(2)` and share the `socket_worker` to the primary worker

  Note over M,W: Start reforking

  M->>M: Create pipe with `stdin_in, stdin_out = IO.pipe`
  M->>P: Send the pipe with `socket_master.send_io(stdin_in)`
  P->>W: `fork(2)` and share the `stdin_in` to the worker

  Note over M,W: After reforking

  M->>W: Send LSP message via the pipe
Loading

Alternative approach

I considered the following approach also.

  • Prepare the IO when forking the primary worker.
    • sequenceDiagram
        participant M as Master
        participant P as Primary Worker
        participant W as Grandchild Worker
      
        M->>M: Create pipes for all grandchild workers with `pipes = (worker_count - 1).times.map { IO.pipe }`
        M->>P: `fork(2)` and share the `pipes` to the primary worker
      
        Note over M,W: Start reforking
      
        P->>W: `fork(2)` and share the `pipes` to the worker
      
        Note over M,W: After reforking
      
        M->>W: Send LSP message via the pipe
      
      Loading
    • It's not efficient because Steep allocates FD too early.
  • Use named pipe or UNIX socket and make a convention for the path name
    • For example, use file in ~/.steep/pipes/#{master-pid}-#{worker_index} to communicate between the master and the grandchild.
    • In this case, it just needs to share the naming convention instead of the pipe itself.
    • But we need to care about security to avoid other processes accessing the pipe.
      • socketpair(2) is more secure.

How to monitor the grandchild process

I needed to change how to monitor the worker processes. Because waitpid(2) allows monitoring only for the direct child processes, but Steep forks workers from the primary worker. So the master process needs to monitor grandchild processes.

I changed the primary worker to monitor the reforked workers.
The primary worker traps SIGCHLD signal. This signal is triggered by any child process exits.
The signal handler checks all exited processes by waitpid(2) with WNOHANG. If the exited process is one of the workers, the primary worker process dies.

https://github.com/soutaro/steep/pull/1492/files#diff-0de0797cd551532b3e8ae27d86e3d2a768f05dba817d6ef009f401d1c30626cbR69-R75

After refork, the master process only monitors the primary worker process. When a grandchild worker process unexpectedly exits, the master recognizes it as the primary worker's exit.

@@ -7,7 +7,7 @@ PATH
csv (>= 3.0.9)
fileutils (>= 1.1.0)
json (>= 2.1.0)
language_server-protocol (>= 3.15, < 4.0)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@pocke pocke marked this pull request as ready for review February 25, 2025 06:31
@soutaro
Copy link
Owner

soutaro commented Feb 28, 2025

Thanks @pocke ! Great work!! 👏

I'm wondering if it's necessary to fork type check workers from the main process at the first time, not forking from the primary worker from the first time. It seems like there is (almost) no benefit to do so because the memory shared cannot be increased. So, wondering if changing the architecture to fork from the primary process from the first time makes the code simpler.

@@ -124,6 +124,18 @@ module Steep

def self.response: (String id, result) -> untyped
end

module Refork
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you add a comment to explain what the request is doing, who sends the request, ...?

@soutaro soutaro added this to the Steep 1.10 milestone Feb 28, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants