Skip to content
This repository was archived by the owner on May 14, 2026. It is now read-only.

Commit 5935986

Browse files
authored
fix(parallel): cache factory modules and probe count once to avoid double calls (#26)
Signed-off-by: assagman <ahmetsercansagman@gmail.com>
1 parent cf6d6fa commit 5935986

2 files changed

Lines changed: 183 additions & 7 deletions

File tree

internal/module/parallel.go

Lines changed: 94 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,13 @@ type Parallel struct {
6868
// Runtime state for logging/context (captured once)
6969
moduleInfoOnce sync.Once
7070
moduleInfo parallelModuleInfo
71+
72+
mu sync.Mutex
73+
74+
// Factory cache to avoid double factory calls during probing and execution
75+
factoryCache []core.Module
76+
factoryCount int
77+
factoryCounted bool
7178
}
7279

7380
// getParallelModuleInfo extracts module type and LM model information for logging
@@ -214,12 +221,21 @@ func NewParallel(module core.Module) *Parallel {
214221
batchKey: "_batch",
215222
repeat: 1,
216223
verbose: false,
224+
factoryCache: nil,
225+
factoryCount: 0,
226+
factoryCounted: false,
217227
}
218228
}
219229

220230
// NewParallelWithFactory creates a Parallel module with a factory function.
221231
// The factory is called for each task with the task index.
222-
// This is the recommended approach for stateful modules.
232+
//
233+
// When inputs don't specify a batch (_batch or slices), Parallel probes the
234+
// factory to determine module count by calling factory(i) until it returns nil.
235+
// The factory MUST return nil for out-of-bounds indices.
236+
//
237+
// In factory mode, WithRepeat(n) is ignored as the factory count determines
238+
// the number of parallel tasks.
223239
func NewParallelWithFactory(factory func(i int) core.Module) *Parallel {
224240
return &Parallel{
225241
factory: factory,
@@ -231,11 +247,17 @@ func NewParallelWithFactory(factory func(i int) core.Module) *Parallel {
231247
batchKey: "_batch",
232248
repeat: 1,
233249
verbose: false,
250+
factoryCache: nil,
251+
factoryCount: 0,
252+
factoryCounted: false,
234253
}
235254
}
236255

237256
// NewParallelWithInstances creates a Parallel module with pre-created instances.
238-
// Each task will use instances[i % len(instances)].
257+
// Each task uses instances[i % len(instances)].
258+
//
259+
// By default, repeat is set to len(instances), so a single input runs through
260+
// all instances. Override with WithRepeat() if using batch inputs.
239261
func NewParallelWithInstances(instances []core.Module) *Parallel {
240262
if len(instances) == 0 {
241263
panic("NewParallelWithInstances: instances slice cannot be empty")
@@ -248,8 +270,11 @@ func NewParallelWithInstances(instances []core.Module) *Parallel {
248270
returnAll: true,
249271
onlySuccessful: true,
250272
batchKey: "_batch",
251-
repeat: 1,
273+
repeat: len(instances), // Auto-set to run all instances with same input
252274
verbose: false,
275+
factoryCache: nil,
276+
factoryCount: 0,
277+
factoryCounted: false,
253278
}
254279
}
255280

@@ -367,7 +392,7 @@ func (p *Parallel) Forward(ctx context.Context, inputs map[string]any) (*core.Pr
367392
}()
368393

369394
// Expand inputs into batch
370-
batch, err := p.expandInputs(inputs)
395+
batch, err := p.expandInputs(ctx, inputs)
371396
if err != nil {
372397
predErr = fmt.Errorf("failed to expand inputs: %w", err)
373398
return nil, predErr
@@ -416,6 +441,14 @@ func (p *Parallel) Forward(ctx context.Context, inputs map[string]any) (*core.Pr
416441
// Module getter
417442
getModule := func(i int) core.Module {
418443
if p.factory != nil {
444+
// Check cache first (probed during expandInputs)
445+
p.mu.Lock()
446+
if i < len(p.factoryCache) && p.factoryCache[i] != nil {
447+
mod := p.factoryCache[i]
448+
p.mu.Unlock()
449+
return mod
450+
}
451+
p.mu.Unlock()
419452
return p.factory(i)
420453
}
421454
if len(p.instances) > 0 {
@@ -712,7 +745,7 @@ func (p *Parallel) Forward(ctx context.Context, inputs map[string]any) (*core.Pr
712745
}
713746

714747
// expandInputs converts inputs into a slice of input maps
715-
func (p *Parallel) expandInputs(inputs map[string]any) ([]map[string]any, error) {
748+
func (p *Parallel) expandInputs(ctx context.Context, inputs map[string]any) ([]map[string]any, error) {
716749
// Check for explicit batch
717750
if batchVal, ok := inputs[p.batchKey]; ok {
718751
batch, ok := batchVal.([]map[string]any)
@@ -760,7 +793,26 @@ func (p *Parallel) expandInputs(inputs map[string]any) ([]map[string]any, error)
760793
return batch, nil
761794
}
762795

763-
// No batch, no slices - repeat if configured
796+
// No batch, no slices - check for factory-based module count
797+
if p.factory != nil {
798+
// Log warning if WithRepeat was also used
799+
if p.repeat > 1 {
800+
logging.GetLogger().Warn(ctx, "Parallel: WithRepeat(n) ignored because factory is present. Factory count takes precedence.", map[string]any{
801+
"repeat_ignored": p.repeat,
802+
})
803+
}
804+
// Probe factory to determine module count
805+
n := p.probeFactoryCount()
806+
batch := make([]map[string]any, n)
807+
for i := 0; i < n; i++ {
808+
taskInputs := make(map[string]any)
809+
maps.Copy(taskInputs, inputs)
810+
batch[i] = taskInputs
811+
}
812+
return batch, nil
813+
}
814+
815+
// Repeat if configured
764816
if p.repeat > 1 {
765817
batch := make([]map[string]any, p.repeat)
766818
for i := 0; i < p.repeat; i++ {
@@ -776,6 +828,39 @@ func (p *Parallel) expandInputs(inputs map[string]any) ([]map[string]any, error)
776828
return []map[string]any{inputs}, nil
777829
}
778830

831+
// probeFactoryCount determines how many modules the factory can create
832+
// by calling factory(i) until it returns nil. Returns at least 1.
833+
func (p *Parallel) probeFactoryCount() int {
834+
p.mu.Lock()
835+
defer p.mu.Unlock()
836+
if p.factoryCounted {
837+
return p.factoryCount
838+
}
839+
if p.factory == nil {
840+
p.factoryCount = 1
841+
p.factoryCounted = true
842+
return 1
843+
}
844+
845+
const maxProbe = 1000 // Safety limit
846+
var cache []core.Module
847+
for i := 0; i < maxProbe; i++ {
848+
mod := p.factory(i)
849+
if mod == nil {
850+
p.factoryCount = i
851+
p.factoryCache = cache
852+
p.factoryCounted = true
853+
return p.factoryCount
854+
}
855+
cache = append(cache, mod)
856+
}
857+
858+
p.factoryCount = maxProbe
859+
p.factoryCache = cache
860+
p.factoryCounted = true
861+
return maxProbe
862+
}
863+
779864
// summarizeLatencies calculates min/max/avg/p50 from latencies
780865
func summarizeLatencies(latencies []time.Duration) struct {
781866
MinMs int64 `json:"min_ms"`
@@ -857,6 +942,9 @@ func (p *Parallel) Clone() core.Module {
857942
batchKey: p.batchKey,
858943
repeat: p.repeat,
859944
verbose: p.verbose,
945+
factoryCache: nil,
946+
factoryCount: 0,
947+
factoryCounted: false,
860948
}
861949

862950
// Only clone module if it exists

internal/module/parallel_test.go

Lines changed: 89 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1271,6 +1271,7 @@ type capturingLogger struct {
12711271
mu sync.Mutex
12721272
infos []logEntry
12731273
debugs []logEntry
1274+
warns []logEntry
12741275
}
12751276

12761277
type logEntry struct {
@@ -1300,7 +1301,13 @@ func (c *capturingLogger) Debug(ctx context.Context, message string, fields map[
13001301
}
13011302

13021303
func (c *capturingLogger) Warn(ctx context.Context, message string, fields map[string]any) {
1303-
// Not used in these tests
1304+
c.mu.Lock()
1305+
defer c.mu.Unlock()
1306+
c.warns = append(c.warns, logEntry{
1307+
level: "warn",
1308+
message: message,
1309+
fields: fields,
1310+
})
13041311
}
13051312

13061313
func (c *capturingLogger) Error(ctx context.Context, message string, fields map[string]any) {
@@ -1333,6 +1340,7 @@ func (c *capturingLogger) reset() {
13331340
defer c.mu.Unlock()
13341341
c.infos = c.infos[:0]
13351342
c.debugs = c.debugs[:0]
1343+
c.warns = c.warns[:0]
13361344
}
13371345

13381346
// mockLMWithName is a MockLM that returns a specific model name
@@ -2381,3 +2389,83 @@ func TestParallelWithFactory_ExactFactoryCalls(t *testing.T) {
23812389
t.Errorf("Expected 3 factory calls, got %d", calls)
23822390
}
23832391
}
2392+
2393+
func TestParallelFactoryCaching(t *testing.T) {
2394+
t.Parallel()
2395+
sig := core.NewSignature("Test").
2396+
AddOutput("value", core.FieldTypeString, "Value")
2397+
2398+
var factoryCalls atomic.Int32
2399+
2400+
factory := func(i int) core.Module {
2401+
factoryCalls.Add(1)
2402+
if i >= 3 {
2403+
return nil
2404+
}
2405+
lm := &MockLM{
2406+
GenerateFunc: func(ctx context.Context, messages []core.Message, options *core.GenerateOptions) (*core.GenerateResult, error) {
2407+
return &core.GenerateResult{
2408+
Content: "[[ ## value ## ]]\nsuccess",
2409+
Usage: core.Usage{TotalTokens: 1, Cost: 0.001},
2410+
}, nil
2411+
},
2412+
}
2413+
return NewPredict(sig, lm)
2414+
}
2415+
2416+
p := NewParallelWithFactory(factory).WithMaxWorkers(2)
2417+
2418+
_, err := p.Forward(context.Background(), map[string]any{"some_input": "val"})
2419+
if err != nil {
2420+
t.Fatalf("Forward failed: %v", err)
2421+
}
2422+
2423+
if factoryCalls.Load() != 4 {
2424+
t.Errorf("Expected exactly 4 factory calls (3 successes + 1 nil probe), got %d", factoryCalls.Load())
2425+
}
2426+
}
2427+
2428+
func TestParallelWithRepeatWarning(t *testing.T) {
2429+
capturingLog := &capturingLogger{}
2430+
originalLogger := logging.GetLogger()
2431+
defer logging.SetLogger(originalLogger)
2432+
logging.SetLogger(capturingLog)
2433+
2434+
sig := core.NewSignature("Test")
2435+
factory := func(i int) core.Module {
2436+
if i >= 1 {
2437+
return nil
2438+
}
2439+
return NewPredict(sig, &MockLM{
2440+
GenerateFunc: func(ctx context.Context, messages []core.Message, options *core.GenerateOptions) (*core.GenerateResult, error) {
2441+
return &core.GenerateResult{
2442+
Content: "[[ ## result ## ]]\nok",
2443+
Usage: core.Usage{},
2444+
}, nil
2445+
},
2446+
})
2447+
}
2448+
2449+
p := NewParallelWithFactory(factory).WithRepeat(5)
2450+
2451+
_, err := p.Forward(context.Background(), map[string]any{"dummy": "val"})
2452+
if err != nil {
2453+
t.Fatalf("Forward failed: %v", err)
2454+
}
2455+
2456+
capturingLog.mu.Lock()
2457+
defer capturingLog.mu.Unlock()
2458+
2459+
found := false
2460+
expectedMsg := "Parallel: WithRepeat(n) ignored because factory is present. Factory count takes precedence."
2461+
for _, w := range capturingLog.warns {
2462+
if w.message == expectedMsg {
2463+
found = true
2464+
break
2465+
}
2466+
}
2467+
2468+
if !found {
2469+
t.Errorf("Expected warning message %q not found in captured logs", expectedMsg)
2470+
}
2471+
}

0 commit comments

Comments
 (0)