Fiber fork cost scales with the parent's FiberRef count: getFiberRefs() full-copy + forkAs full-iteration per child
Repo: effect-ts/effect
Location: packages/effect/src/internal/fiberRuntime.ts:2470-2490 (unsafeMakeChildFiber) → fiberRuntime.ts:559-560 (getFiberRefs) → fiberRefs.ts:129-152 (forkAs) and fiberRefs.ts:216 (updateAs copy)
Severity: medium · Confidence: 0.9
Type: complexity-at-a-distance
Description
unsafeMakeChildFiber (packages/effect/src/internal/fiberRuntime.ts:2470) runs on every Effect.fork/forkDaemon/race/zip-concurrent. It calls parentFiber.getFiberRefs() (fiberRuntime.ts:559-560), which unconditionally calls setFiberRef(currentRuntimeFlags, ...) — triggering fiberRefs.updateAs's full new Map(self.locals) copy (fiberRefs.ts:216) even when the flags are unchanged, because the copy happens before unsafeUpdateAs's no-op early return. It then calls fiberRefs.forkAs (fiberRefs.ts:129-152), which iterates EVERY ref, evaluating fiberRef.patch(fiberRef.fork)(oldValue) plus an Equal.equals per ref, and builds another full Map. Net: two O(R) map builds per fork. Cross-module consequence: forking M fibers under a context where libraries (tracing, logging annotations, schedulers, user config via Effect.locally) have populated R fiber refs costs O(M·R) — fork-heavy code in one module slows down because unrelated modules set refs. Validated on effect 3.21.4: 2,000 fork+await of Effect.succeed(1) went from 8.9ms with 10 parent refs to 105.4ms with 500 (11.9x).
Benchmark
Holding fork count fixed at 2,000 (fork + await of a trivial effect) while sweeping the number R of FiberRefs set on the parent, median time should grow roughly linearly with R because each fork pays two O(R) FiberRefs Map traversals/copies. Measured: 11.9x at R=500 vs R=10 (O(1)-per-fork predicts 1.0x).
Observed complexity: O(R)
| Input | median_ms | ratio | note |
|-------|-----------|-------|------|
| R=10 | 10.26 | 1.00 | — |
| R=50 | 17.44 | 1.70 | — |
| R=100 | 30.36 | 2.96 | — |
| R=500 | 109.81 | 10.70 | — |
benchmark confirmed
Fiber fork cost scales with the parent's FiberRef count: getFiberRefs() full-copy + forkAs full-iteration per child
Repo:
effect-ts/effectLocation: packages/effect/src/internal/fiberRuntime.ts:2470-2490 (unsafeMakeChildFiber) → fiberRuntime.ts:559-560 (getFiberRefs) → fiberRefs.ts:129-152 (forkAs) and fiberRefs.ts:216 (updateAs copy)
Severity: medium · Confidence: 0.9
Type: complexity-at-a-distance
Description
unsafeMakeChildFiber (packages/effect/src/internal/fiberRuntime.ts:2470) runs on every Effect.fork/forkDaemon/race/zip-concurrent. It calls parentFiber.getFiberRefs() (fiberRuntime.ts:559-560), which unconditionally calls setFiberRef(currentRuntimeFlags, ...) — triggering fiberRefs.updateAs's full
new Map(self.locals)copy (fiberRefs.ts:216) even when the flags are unchanged, because the copy happens before unsafeUpdateAs's no-op early return. It then calls fiberRefs.forkAs (fiberRefs.ts:129-152), which iterates EVERY ref, evaluatingfiberRef.patch(fiberRef.fork)(oldValue)plus an Equal.equals per ref, and builds another full Map. Net: two O(R) map builds per fork. Cross-module consequence: forking M fibers under a context where libraries (tracing, logging annotations, schedulers, user config via Effect.locally) have populated R fiber refs costs O(M·R) — fork-heavy code in one module slows down because unrelated modules set refs. Validated on effect 3.21.4: 2,000 fork+await of Effect.succeed(1) went from 8.9ms with 10 parent refs to 105.4ms with 500 (11.9x).Benchmark
Holding fork count fixed at 2,000 (fork + await of a trivial effect) while sweeping the number R of FiberRefs set on the parent, median time should grow roughly linearly with R because each fork pays two O(R) FiberRefs Map traversals/copies. Measured: 11.9x at R=500 vs R=10 (O(1)-per-fork predicts 1.0x).
Observed complexity: O(R)
benchmark confirmed