Skip to content

Commit 92cbcdc

Browse files
authored
Update pod evaluator to use round-robin to delegate work to krm functions (kptdev#497)
* Update pod evaluator to use round-robin to delegate work to krm functions * Reduce the replica count to 1 for function-runner * Remove 5m context as api-server context has a 291s deadline * Add documentation for fn-runner round-robin mechanism
1 parent e13bdcd commit 92cbcdc

11 files changed

Lines changed: 127 additions & 58 deletions

File tree

deployments/porch/2-function-runner.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ metadata:
2424
name: function-runner
2525
namespace: porch-system
2626
spec:
27-
replicas: 2
27+
replicas: 1
2828
selector:
2929
matchLabels:
3030
app: function-runner

docs/content/en/docs/5_architecture_and_components/function-runner/design.md

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,57 @@ For detailed explanations of how these differences affect operations, see the in
244244
- May recreate pods for functions with irregular usage patterns
245245
- Configurable TTL allows tuning for specific workloads
246246

247+
### Pod Selection Strategy
248+
249+
**Decision**: Use round-robin distribution among pods with equal minimum load.
250+
251+
**Rationale:**
252+
- Ensures even distribution of requests across equally-loaded pods
253+
- Prevents all requests from going to the same pod when multiple pods have equal capacity
254+
- Simple and predictable load balancing behavior
255+
- Works well with parallel execution within pods
256+
257+
**Implementation:**
258+
- Find the minimum waitlist length across all pods for a function
259+
- Use round-robin to select among pods that have the minimum waitlist
260+
- Maintains round-robin index per function to track position
261+
262+
**Alternatives considered:**
263+
- **Pure least-loaded**: Can cause all requests to go to same pod when loads are equal
264+
- **Random selection**: Less predictable distribution patterns
265+
- **Weighted round-robin**: Unnecessary complexity for uniform pod capacities
266+
267+
**Trade-offs:**
268+
- Slightly more complex than pure least-loaded selection
269+
- Requires maintaining round-robin index per function
270+
- Better load distribution justifies the minimal added complexity
271+
272+
### Parallel Execution Model
273+
274+
**Decision**: Allow parallel function evaluations within the same pod.
275+
276+
**Rationale:**
277+
- Improves throughput by utilizing pod capacity fully
278+
- Reduces wait times when multiple requests target the same function
279+
- Simplifies code by removing serialization mutex
280+
- Works well with round-robin pod selection for even load distribution
281+
282+
**Implementation:**
283+
- Removed per-pod mutex that was serializing executions
284+
- Concurrent executions tracked via atomic counter
285+
- gRPC connection shared across parallel calls to same pod
286+
- Context timeout prevents goroutine leaks
287+
288+
**Alternatives considered:**
289+
- **Serial execution per pod**: Simpler but underutilizes pod capacity
290+
- **Connection pooling**: More complex, unnecessary with gRPC multiplexing
291+
- **Per-function concurrency limits**: Added complexity without clear benefit
292+
293+
**Trade-offs:**
294+
- Functions must be safe for concurrent execution (standard KRM function requirement)
295+
- Multiple evaluations share pod resources (CPU, memory)
296+
- Better resource utilization justifies the concurrency model
297+
247298
### Service Mesh Compatibility
248299

249300
**Decision**: Use ClusterIP services as frontends for function pods.

docs/content/en/docs/5_architecture_and_components/function-runner/functionality/function-evaluation.md

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -437,14 +437,20 @@ The evaluation system employs several performance strategies.
437437
- Multiple requests can execute concurrently
438438
- Each request gets own gRPC connection
439439
- Pod cache manager coordinates access
440-
- Waitlist prevents duplicate pod creation
440+
- Round-robin load balancing across equal-load pods
441441

442442
**Concurrency characteristics:**
443-
- Same function, same pod: Sequential (one at a time)
443+
- Same function, same pod: Parallel execution supported
444444
- Same function, different pods: Concurrent
445445
- Different functions: Fully concurrent
446446
- No artificial concurrency limits
447447

448+
**Load balancing:**
449+
- Requests distributed to least-loaded pods
450+
- Round-robin among pods with equal load
451+
- Ensures even work distribution
452+
- Prevents hotspotting on single pod
453+
448454
### Resource Limits
449455

450456
**Resource considerations:**

docs/content/en/docs/5_architecture_and_components/function-runner/functionality/pod-lifecycle-management.md

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ The Pod Cache Manager runs as a single goroutine managing all pod caching operat
3434

3535
The manager maintains two data structures:
3636

37-
**Cache Map** - Maps function image names to pod information and gRPC client connections. Each entry contains the pod namespace/name and an active gRPC client connection to the pod's wrapper server.
37+
**Cache Map** - Maps function image names to pod information and gRPC client connections. Each entry contains the pod namespace/name and an active gRPC client connection to the pod's wrapper server. When multiple pods serve the same function, the cache uses a round-robin index to distribute requests evenly across pods with equal load.
3838

3939
**Waitlist Map** - Maps function image names to lists of channels waiting for pod readiness. When multiple evaluation requests arrive for the same function before a pod is ready, they queue in the waitlist to prevent duplicate pod creation.
4040

@@ -346,9 +346,14 @@ The system supports concurrent operations efficiently:
346346
- Each request gets own gRPC connection
347347
- Pod cache manager coordinates access via channels
348348
- Waitlist prevents duplicate pod creation
349+
- Function evaluations can execute in parallel within the same pod
350+
351+
**Pod Selection Strategy:**
352+
353+
When multiple pods serve the same function, the cache manager selects the pod with the least load (smallest waitlist length). When multiple pods have equal load, the system uses round-robin distribution to ensure even load balancing across pods rather than always selecting the first available pod.
349354

350355
**Concurrency benefits:**
351-
- Same function, same pod: Sequential (one at a time)
356+
- Same function, same pod: Parallel (multiple evaluations execute concurrently)
352357
- Same function, different pods: Concurrent
353358
- Different functions: Fully concurrent
354359
- No artificial concurrency limits

docs/content/en/docs/5_architecture_and_components/function-runner/interactions.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,10 +141,12 @@ Pod Pod Pod
141141

142142
**Execution pattern:**
143143
- Pod cache checked for existing pod (reuse if available)
144+
- Pod selection uses round-robin among pods with minimum waitlist length
144145
- Cache miss triggers pod creation with wrapper server
145146
- ClusterIP service provides stable DNS-based access
146147
- gRPC connection to wrapper server in pod
147148
- Wrapper server executes function binary and returns results
149+
- Multiple evaluations can execute in parallel on the same pod
148150

149151
**For detailed pod lifecycle, see [Pod Lifecycle Management]({{% relref "/docs/5_architecture_and_components/function-runner/functionality/pod-lifecycle-management.md" %}}).**
150152

@@ -430,7 +432,8 @@ The Function Runner handles concurrent operations safely:
430432

431433
**Concurrency characteristics:**
432434
- Multiple function executions can run concurrently
433-
- Each request gets own gRPC connection
435+
- Multiple evaluations can run in parallel on the same pod
436+
- Each request gets own gRPC connection to pod
434437
- Pod cache manager coordinates access via channels
435438
- Waitlist prevents duplicate pod creation
436439

docs/content/en/docs/6_configuration_and_deployments/configurations/components/function-runner-config/_index.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,7 @@ metadata:
149149
name: function-runner
150150
namespace: porch-system
151151
spec:
152-
replicas: 2
152+
replicas: 1
153153
selector:
154154
matchLabels:
155155
app: function-runner

func/internal/podcachemanager.go

Lines changed: 26 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,8 @@ type podCacheConfigEntry struct {
7171
type functionInfo struct {
7272
// status of all pods belonging to the same KRM function image
7373
pods []functionPodInfo
74+
// roundRobinIdx is used to distribute requests across pods when all have equal load
75+
roundRobinIdx int
7476
}
7577

7678
// functionPodInfo represents the state of a single pod instance.
@@ -82,8 +84,6 @@ type functionPodInfo struct {
8284
waitlist []chan<- *connectionResponse
8385
// time of last function evaluation, used by the garbage collector to identify idle pods
8486
lastActivity time.Time
85-
// mutex used to prevent concurrent fn evaluations in the same pod
86-
fnEvaluationMutex *sync.Mutex
8787
// the number of currently ongoing and waiting fn evaluations in the pod
8888
concurrentEvaluations *atomic.Int32
8989
}
@@ -369,25 +369,39 @@ func forEachConcurrently(m []podCacheConfigEntry, fn func(k podCacheConfigEntry)
369369
wg.Wait()
370370
}
371371

372-
// findBestPod returns with the index of the least loaded healthy pod for the given function.
372+
// findBestPod returns with the index of the best pod for the given function.
373+
// It uses round-robin among pods with equal load to ensure even distribution.
373374
// If there are no suitable pods, it returns with -1.
374375
func (pcm *podCacheManager) findBestPod(fn *functionInfo) (int, int) {
375376
if fn == nil {
376377
return -1, 0
377378
}
378-
if len(fn.pods) == 0 {
379+
n := len(fn.pods)
380+
if n == 0 {
379381
return -1, 0
380382
}
381-
bestPodIdx := 0
382-
bestWaitlistLen := fn.pods[0].WaitlistLen()
383-
for i := 1; i < len(fn.pods); i++ {
384-
waitlistLen := fn.pods[i].WaitlistLen()
385-
if waitlistLen < bestWaitlistLen {
386-
bestPodIdx = i
387-
bestWaitlistLen = waitlistLen
383+
384+
minWaitlist := 0
385+
// Find the minimum waitlist length across all pods
386+
minWaitlist = fn.pods[0].WaitlistLen()
387+
for i := 1; i < n; i++ {
388+
wl := fn.pods[i].WaitlistLen()
389+
if wl < minWaitlist {
390+
minWaitlist = wl
388391
}
389392
}
390-
return bestPodIdx, bestWaitlistLen
393+
394+
// Round-robin among pods that have the minimum waitlist length
395+
for i := 0; i < n; i++ {
396+
idx := (fn.roundRobinIdx + i) % n
397+
if fn.pods[idx].WaitlistLen() == minWaitlist {
398+
fn.roundRobinIdx = (idx + 1) % n
399+
return idx, minWaitlist
400+
}
401+
}
402+
403+
// This should never happen since minWaitlist was calculated from these same pods
404+
return -1, 0
391405
}
392406

393407
// removeUnhealthyPods removes unhealthy pods from the function's pod list.
@@ -539,7 +553,6 @@ func NewPodInfo(firstResponseCh chan<- *connectionResponse) functionPodInfo {
539553
podData: nil, // This will be filled in when the pod is ready.
540554
lastActivity: time.Now(),
541555
concurrentEvaluations: &atomic.Int32{},
542-
fnEvaluationMutex: &sync.Mutex{},
543556
}
544557
if firstResponseCh != nil {
545558
pod.waitlist = append(pod.waitlist, firstResponseCh)
@@ -564,7 +577,6 @@ func (pod *functionPodInfo) SendResponse(responseCh chan<- *connectionResponse,
564577
default:
565578
responseCh <- &connectionResponse{
566579
podData: *pod.podData,
567-
fnEvaluationMutex: pod.fnEvaluationMutex,
568580
concurrentEvaluations: pod.concurrentEvaluations,
569581
err: nil,
570582
}

func/internal/podcachemanager_unit_test.go

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,6 @@ func makePodInfoWithLoad(load int32) functionPodInfo {
3939
counter.Store(load)
4040
return functionPodInfo{
4141
concurrentEvaluations: counter,
42-
fnEvaluationMutex: &sync.Mutex{},
4342
lastActivity: time.Now(),
4443
waitlist: []chan<- *connectionResponse{},
4544
}
@@ -57,7 +56,6 @@ func makeReadyPodInfo(image string, podKey, serviceKey client.ObjectKey, grpcCon
5756
grpcConnection: grpcConn,
5857
},
5958
concurrentEvaluations: counter,
60-
fnEvaluationMutex: &sync.Mutex{},
6159
lastActivity: time.Now(),
6260
waitlist: []chan<- *connectionResponse{},
6361
}
@@ -301,7 +299,6 @@ func TestNewPodInfo(t *testing.T) {
301299
assert.Nil(t, pod.podData)
302300
assert.Empty(t, pod.waitlist)
303301
assert.Equal(t, int32(0), pod.concurrentEvaluations.Load())
304-
assert.NotNil(t, pod.fnEvaluationMutex)
305302
})
306303

307304
t.Run("non-nil channel adds to waitlist and increments counter", func(t *testing.T) {
@@ -318,7 +315,6 @@ func TestSendResponse(t *testing.T) {
318315
pod := &functionPodInfo{
319316
podData: &podData{image: "test"},
320317
concurrentEvaluations: &atomic.Int32{},
321-
fnEvaluationMutex: &sync.Mutex{},
322318
}
323319
ch := make(chan *connectionResponse, 1)
324320
testErr := fmt.Errorf("test error")
@@ -334,7 +330,6 @@ func TestSendResponse(t *testing.T) {
334330
pod := &functionPodInfo{
335331
podData: nil,
336332
concurrentEvaluations: &atomic.Int32{},
337-
fnEvaluationMutex: &sync.Mutex{},
338333
}
339334
ch := make(chan *connectionResponse, 1)
340335

@@ -359,7 +354,6 @@ func TestSendResponse(t *testing.T) {
359354
serviceKey: &serviceKey,
360355
},
361356
concurrentEvaluations: &atomic.Int32{},
362-
fnEvaluationMutex: &sync.Mutex{},
363357
}
364358
ch := make(chan *connectionResponse, 1)
365359

@@ -369,7 +363,6 @@ func TestSendResponse(t *testing.T) {
369363
assert.NoError(t, resp.err)
370364
assert.Equal(t, "test-image", resp.podData.image)
371365
assert.NotNil(t, resp.grpcConnection)
372-
assert.NotNil(t, resp.fnEvaluationMutex)
373366
assert.NotNil(t, resp.concurrentEvaluations)
374367
})
375368
}
@@ -461,7 +454,6 @@ func TestRemoveUnhealthyPods(t *testing.T) {
461454
{
462455
podData: nil, // under creation
463456
concurrentEvaluations: &atomic.Int32{},
464-
fnEvaluationMutex: &sync.Mutex{},
465457
},
466458
},
467459
}

func/internal/podevaluator.go

Lines changed: 18 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@ package internal
1717
import (
1818
"context"
1919
"fmt"
20-
"sync"
2120
"sync/atomic"
2221
"time"
2322

@@ -93,8 +92,6 @@ type connectionRequest struct {
9392

9493
type connectionResponse struct {
9594
podData
96-
// mutex used to prevent concurrent fn evaluations in the same pod
97-
fnEvaluationMutex *sync.Mutex
9895
// the number of currently ongoing and waiting fn evaluations in the pod
9996
concurrentEvaluations *atomic.Int32
10097
// err indicates the error that prevents us to allocate a pod for the fn evaluator
@@ -215,23 +212,25 @@ func (pe *podEvaluator) EvaluateFunction(ctx context.Context, req *evaluator.Eva
215212
}
216213

217214
// Waiting for the client from the channel. This step is blocking.
218-
pod := <-responseChannel
219-
if pod == nil || pod.grpcConnection == nil || pod.err != nil {
220-
return nil, fmt.Errorf("unable to get the grpc client to the pod for %v: %w", req.Image, pod.err)
221-
}
215+
select {
216+
case pod := <-responseChannel:
217+
if pod == nil || pod.grpcConnection == nil || pod.err != nil {
218+
return nil, fmt.Errorf("unable to get the grpc client to the pod for %v: %w", req.Image, pod.err)
219+
}
222220

223-
defer pod.concurrentEvaluations.Add(-1)
224-
pod.fnEvaluationMutex.Lock()
225-
defer pod.fnEvaluationMutex.Unlock()
221+
defer pod.concurrentEvaluations.Add(-1)
226222

227-
resp, err := evaluator.NewFunctionEvaluatorClient(pod.grpcConnection).EvaluateFunction(ctx, req)
228-
if err != nil {
229-
klog.V(4).Infof("Resource List: %s", req.ResourceList)
230-
return nil, fmt.Errorf("unable to evaluate %v with pod evaluator: %w", req.Image, err)
231-
}
232-
// Log stderr when the function succeeded. If the function fails, stderr will be surfaced to the users.
233-
if len(resp.Log) > 0 {
234-
klog.Warningf("evaluating %v succeeded, but stderr is: %v", req.Image, string(resp.Log))
223+
resp, err := evaluator.NewFunctionEvaluatorClient(pod.grpcConnection).EvaluateFunction(ctx, req)
224+
if err != nil {
225+
klog.V(4).Infof("Resource List: %s", req.ResourceList)
226+
return nil, fmt.Errorf("unable to evaluate %v with pod evaluator: %w", req.Image, err)
227+
}
228+
// Log stderr when the function succeeded. If the function fails, stderr will be surfaced to the users.
229+
if len(resp.Log) > 0 {
230+
klog.Warningf("evaluating %v succeeded, but stderr is: %v", req.Image, string(resp.Log))
231+
}
232+
return resp, nil
233+
case <-ctx.Done():
234+
return nil, fmt.Errorf("function evaluation timed out for %v: %w", req.Image, ctx.Err())
235235
}
236-
return resp, nil
237236
}

func/internal/podevaluator_porch_serial_execution_test.go renamed to func/internal/podevaluator_porch_parallel_execution_test.go

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ func startFakeServer(ctx context.Context, t *testing.T, delay time.Duration) (st
5353
return addr, nil
5454
}
5555

56-
func TestPodEvaluatorExecutionSerial(t *testing.T) {
56+
func TestPodEvaluatorExecutionParallel(t *testing.T) {
5757
const sleep = 2 * time.Second
5858

5959
ctx, cancel := context.WithCancel(context.Background())
@@ -75,16 +75,16 @@ func TestPodEvaluatorExecutionSerial(t *testing.T) {
7575

7676
reqCh := make(chan *connectionRequest, 2)
7777
go func() {
78-
lock := &sync.Mutex{}
7978
counter := &atomic.Int32{}
8079
for req := range reqCh {
80+
// Increment counter to simulate single pod with limited concurrency
81+
counter.Add(1)
8182
req.responseCh <- &connectionResponse{
8283
podData: podData{
8384
image: req.image,
8485
grpcConnection: conn,
8586
podKey: ptr.To(client.ObjectKey{}),
8687
},
87-
fnEvaluationMutex: lock,
8888
concurrentEvaluations: counter,
8989
err: nil,
9090
}
@@ -116,7 +116,13 @@ func TestPodEvaluatorExecutionSerial(t *testing.T) {
116116
t.Logf("durations: %v", durations)
117117

118118
slices.Sort(durations)
119-
if durations[funcCallCount-1] < time.Duration(funcCallCount)*sleep {
120-
t.Errorf("expected serial but slowest took only %v", durations[funcCallCount-1])
119+
// Since the current implementation allows parallel execution on the same connection,
120+
// we should expect parallel behavior, not serial. Update the test expectation.
121+
if durations[funcCallCount-1] > time.Duration(funcCallCount)*sleep {
122+
t.Errorf("expected parallel but execution took too long: %v", durations[funcCallCount-1])
123+
}
124+
// Verify that all calls completed in roughly the same time (parallel execution)
125+
if durations[funcCallCount-1]-durations[0] > sleep/2 {
126+
t.Errorf("expected parallel execution but calls had significant time difference: %v", durations[funcCallCount-1]-durations[0])
121127
}
122128
}

0 commit comments

Comments
 (0)