Skip to main content

pipeline efficiency

GitLab CI/CD Pipeline Efficiency

Optimize pipeline performance using needs, dependencies, parallelization, and DAG patterns.

Table of Contents

Understanding Pipeline Execution

Sequential Stages (Traditional)

Without optimization:

stages: - build - test - deploy build: stage: build script: npm run build test: stage: test script: npm test deploy: stage: deploy script: ./deploy.sh

Timeline:

build (5 min)
  
test (10 min)
  
deploy (3 min)

Total: 18 minutes

All test stage jobs wait for ALL build stage jobs to complete.

Source: GitLab CI/CD Pipelines

DAG with needs (Optimized)

With needs keyword:

stages: - build - test - deploy build: stage: build script: npm run build test: stage: test needs: [build] # Only wait for build, not entire stage script: npm test deploy: stage: deploy needs: [test] # Only wait for test script: ./deploy.sh

Timeline (identical in this simple case):

build (5 min)
  
test (10 min)
  
deploy (3 min)

Total: 18 minutes

But with parallel jobs, the difference is dramatic...

needs vs dependencies

Key Differences

Featureneedsdependencies
ControlsJob execution orderArtifact downloads
Waits forJob completionJob completion (implicit)
ArtifactsDownloads automaticallyDownloads if specified
DefaultNo artifactsAll artifacts from stage
Stage boundaryIgnores stagesRespects stages

Understanding needs

Purpose: Define explicit job dependencies, run jobs as soon as dependencies complete

stages: - build - test build-frontend: stage: build script: npm run build:frontend artifacts: paths: - dist-frontend/ build-backend: stage: build script: go build -o api ./cmd/api artifacts: paths: - api test-frontend: stage: test needs: [build-frontend] # Wait for build-frontend only script: npm test test-backend: stage: test needs: [build-backend] # Wait for build-backend only script: go test ./...

Timeline:

build-frontend (3 min)    build-backend (5 min)
                            
test-frontend (2 min)     test-backend (8 min)

Total: max(3+2, 5+8) = 13 minutes (instead of 18 minutes sequential)

Key benefit: Jobs run in parallel when dependencies allow, even across stages.

Source: GitLab needs Documentation

Understanding dependencies

Purpose: Control which artifacts to download (optimization to reduce transfer time)

build: stage: build script: npm run build artifacts: paths: - dist/ - logs/ # 500MB of logs test: stage: test dependencies: [build] # Downloads ALL artifacts (including 500MB logs) script: npm test deploy: stage: deploy dependencies: [] # Download NO artifacts (empty array) script: ./deploy.sh # Uses deployment script, doesn't need build artifacts

With needs (recommended):

test: stage: test needs: - job: build artifacts: true # Download artifacts (default) script: npm test deploy: stage: deploy needs: - job: test artifacts: false # Don't download test artifacts script: ./deploy.sh

Performance gain: Skipping unnecessary artifact downloads can save 30-60 seconds per job.

Source: GitLab CI needs vs dependencies

Practical Examples

Example 1: Parallel Tests with Shared Build

build: stage: build script: npm run build artifacts: paths: - dist/ unit-tests: stage: test needs: - job: build artifacts: true script: npm run test:unit integration-tests: stage: test needs: - job: build artifacts: true script: npm run test:integration e2e-tests: stage: test needs: - job: build artifacts: true script: npm run test:e2e

Timeline:

build (5 min)
     (all three start simultaneously)
unit (3 min) | integration (7 min) | e2e (10 min)

Total: 5 + 10 = 15 minutes (vs 5 + 3 + 7 + 10 = 25 minutes sequential)

Example 2: Multi-Stage with Artifact Optimization

compile: stage: build script: gcc -o app app.c artifacts: paths: - app - compile.log # Large debug output test: stage: test needs: - job: compile artifacts: true # Need the binary script: ./app --test package: stage: package needs: - job: test artifacts: false # Don't need test outputs - job: compile artifacts: true # Need the binary to package script: tar czf app.tar.gz app deploy: stage: deploy needs: - job: package artifacts: true # Need the tarball dependencies: [package] # Explicit: only package artifacts script: scp app.tar.gz server:/opt/

Artifact transfer savings: deploy doesn't download compile.log (potentially 100+ MB).

Directed Acyclic Graph (DAG)

What is a DAG Pipeline?

A DAG (Directed Acyclic Graph) pipeline uses needs to create job dependencies, allowing jobs to run as soon as their specific dependencies complete, regardless of stages.

Traditional Stages:

Stage 1: [A, B, C]  All must complete
  
Stage 2: [D, E, F]  All wait for Stage 1
  
Stage 3: [G, H, I]  All wait for Stage 2

DAG with needs:

A  D  G
B  E  H
C  F  I
(D can start as soon as A completes, even if B and C are still running)

Building a DAG

Complex example:

stages: - prepare - build - test - deploy lint: stage: prepare script: npm run lint build-frontend: stage: build needs: [lint] script: npm run build:frontend artifacts: paths: - dist-frontend/ build-backend: stage: build needs: [lint] script: go build -o api ./cmd/api artifacts: paths: - api test-frontend-unit: stage: test needs: [build-frontend] script: npm run test:unit test-frontend-e2e: stage: test needs: [build-frontend] script: npm run test:e2e test-backend-unit: stage: test needs: [build-backend] script: go test ./... test-backend-integration: stage: test needs: [build-backend, build-frontend] # Needs both script: ./run-integration-tests.sh deploy-frontend: stage: deploy needs: [test-frontend-unit, test-frontend-e2e] script: ./deploy-frontend.sh deploy-backend: stage: deploy needs: [test-backend-unit, test-backend-integration] script: ./deploy-backend.sh

DAG visualization:

                    lint
                   /    \
        build-frontend  build-backend
           /    \          /    \
    test-fe-unit  test-fe-e2e  test-be-unit  test-be-integration
           \    /                    \              /
         deploy-frontend           deploy-backend

Key advantages:

  • Frontend and backend build in parallel after lint
  • Tests run immediately after their build completes
  • Deployments happen as soon as their tests pass
  • No artificial stage-based waiting

Source: Pipeline Efficiency

Parallelization

Parallel Keyword

Run multiple instances of the same job simultaneously.

Example 1: Split Test Suite

test: parallel: 5 script: - npm run test -- --shard=$CI_NODE_INDEX/$CI_NODE_TOTAL

Variables injected:

  • CI_NODE_INDEX: 1, 2, 3, 4, 5
  • CI_NODE_TOTAL: 5

Timeline:

Without parallel:
test (10 minutes)

With parallel: 5
test-1/5 | test-2/5 | test-3/5 | test-4/5 | test-5/5
(2 min each)

Time savings: 10 min  2 min (80% reduction)

Example 2: Matrix Builds

test-matrix: parallel: matrix: - PLATFORM: [linux, macos, windows] NODE_VERSION: [16, 18, 20] script: - npm install - npm test

Generates 9 jobs:

  • test-matrix: [linux, 16]
  • test-matrix: [linux, 18]
  • test-matrix: [linux, 20]
  • test-matrix: [macos, 16]
  • ... (9 total combinations)

Use case: Cross-platform testing, multi-version compatibility

Cost consideration: 9 jobs = 9 CI minutes. Only parallelize when needed.

Optimal Parallel Count

Guidelines:

  1. Test Suite Splitting:

    • Measure single-threaded test time
    • Target 2-5 minutes per parallel job
    • Example: 20 min tests parallel: 5 4 min each
  2. Available Runners:

    • Don't exceed concurrent runner capacity
    • 10 parallel jobs but only 2 runners = no benefit (queuing)
  3. Cache Effectiveness:

    • More parallel jobs = more cache downloads
    • Balance parallelization vs cache overhead
  4. Cost vs Speed:

    • parallel: 10 = 10 CI minutes consumed
    • Optimize for developer time, but monitor costs

Example calculation:

Test suite: 30 minutes
Target time: 5 minutes per job
Parallel count: 30 / 5 = 6

Result: 6 parallel jobs, each running ~5 minutes
Total time: 5 minutes (vs 30 minutes)
CI minutes used: 6  5 = 30 minutes (same total cost, much faster)

Source: GitLab Parallel Jobs

Job Dependencies

Explicit vs Implicit Dependencies

Implicit (stage-based):

stages: - build - test build-all: stage: build script: make all test-a: stage: test script: make test-a # Waits for ALL build stage jobs test-b: stage: test script: make test-b # Also waits for ALL build stage jobs

Explicit (needs-based):

stages: - build - test build-a: stage: build script: make build-a build-b: stage: build script: make build-b test-a: stage: test needs: [build-a] # Only waits for build-a script: make test-a test-b: stage: test needs: [build-b] # Only waits for build-b script: make test-b

Efficiency gain: test-a starts as soon as build-a completes, doesn't wait for build-b.

Cross-Project Dependencies

deploy: stage: deploy needs: - project: my-group/library job: build ref: main artifacts: true script: - ./deploy.sh

Use case: Microservices depending on shared libraries or contracts.

Optional Dependencies

deploy: stage: deploy needs: - job: test optional: true # Deploy even if test is skipped/failed script: ./deploy.sh

Use case: Allow manual testing jobs to be skipped without blocking deployment.

Critical Path Optimization

Identifying the Critical Path

The critical path is the longest chain of dependent jobs, determining minimum pipeline duration.

Example pipeline:

lint: script: npm run lint # 1 min build-frontend: needs: [lint] script: npm run build # 5 min build-backend: needs: [lint] script: go build # 3 min test-frontend: needs: [build-frontend] script: npm test # 10 min test-backend: needs: [build-backend] script: go test # 5 min deploy: needs: [test-frontend, test-backend] script: ./deploy.sh # 2 min

Paths:

  • lint build-frontend test-frontend deploy = 1 + 5 + 10 + 2 = 18 min
  • lint build-backend test-backend deploy = 1 + 3 + 5 + 2 = 11 min

Critical path: Frontend path (18 min) - this determines minimum pipeline time.

Optimizing Critical Path

Strategy 1: Parallelize the Slowest Jobs

test-frontend: needs: [build-frontend] parallel: 5 # Split 10 min test into 5x 2 min jobs script: npm test -- --shard=$CI_NODE_INDEX/$CI_NODE_TOTAL

New critical path: 1 + 5 + 2 + 2 = 10 min (44% reduction)

Strategy 2: Move Work Out of Critical Path

# Run expensive linting in parallel with tests lint-detailed: needs: [build-frontend] script: npm run lint:detailed # Expensive, not blocking allow_failure: true deploy: needs: [test-frontend, test-backend] # Doesn't wait for lint-detailed script: ./deploy.sh

Strategy 3: Cache Optimization

Target jobs on the critical path for aggressive caching:

build-frontend: needs: [lint] cache: key: files: - package-lock.json paths: - node_modules/ - .webpack-cache/ # Speed up the critical path job script: npm run build

Expected improvement: 5 min 2 min after first run with cache.

Performance Patterns

Pattern 1: Fan-Out / Fan-In

Fan-out: One job triggers many parallel jobs Fan-in: Many jobs converge to one job

build: script: npm run build test-unit: needs: [build] parallel: 5 script: npm test test-integration: needs: [build] parallel: 3 script: npm run test:integration test-e2e: needs: [build] script: npm run test:e2e deploy: needs: [test-unit, test-integration, test-e2e] # Fan-in script: ./deploy.sh

Visualization:

        build
      /   |   \
    unit  int  e2e (fan-out)
      \   |   /
       deploy (fan-in)

Pattern 2: Pipeline Gates

Require approval before expensive operations:

build: stage: build script: npm run build test: stage: test needs: [build] script: npm test deploy-staging: stage: deploy needs: [test] when: manual # Gate: requires manual trigger script: ./deploy-staging.sh deploy-production: stage: deploy needs: [deploy-staging] when: manual # Second gate environment: name: production script: ./deploy-production.sh

Cost benefit: Prevents accidental expensive deployments.

Pattern 3: Conditional DAG

Run different paths based on changes:

build-frontend: script: npm run build:frontend rules: - changes: - frontend/**/* build-backend: script: go build rules: - changes: - backend/**/* test-frontend: needs: [build-frontend] rules: - changes: - frontend/**/* script: npm test test-backend: needs: [build-backend] rules: - changes: - backend/**/* script: go test deploy: needs: - job: test-frontend optional: true # May not run if no frontend changes - job: test-backend optional: true # May not run if no backend changes script: ./deploy.sh

Efficiency: Only run relevant portions of the DAG.

Pattern 4: Layered Testing

Quick tests first, expensive tests later:

test-lint: stage: test-fast script: npm run lint # 30 seconds test-unit: stage: test-fast script: npm run test:unit # 2 minutes test-integration: stage: test-slow needs: [test-lint, test-unit] # Only if fast tests pass script: npm run test:integration # 10 minutes test-e2e: stage: test-slow needs: [test-lint, test-unit] script: npm run test:e2e # 15 minutes

Benefit: Fail fast on cheap tests before running expensive ones (cost savings).

Performance Benchmarks

Expected Improvements

OptimizationTime ReductionComplexity
needs instead of stages30-50%Low
Parallel test splitting50-80%Medium
DAG with needs40-60%Medium
Critical path optimization30-70%High
Artifact optimization5-15%Low

Real-World Example

Before optimization:

Stages: build  test  deploy
Duration: 5 min + 20 min + 3 min = 28 minutes

After optimization:

- Used needs for DAG - Parallelized tests (parallel: 5) - Optimized artifacts (artifacts: false where not needed) - Added caching Result: 5 + 4 + 3 = 12 minutes (57% reduction)

Debugging Pipeline Performance

Analyze Job Timing

GitLab UI: CI/CD Pipelines Select pipeline View DAG

Look for:

  • Jobs with long duration (optimize these first)
  • Jobs waiting unnecessarily (add needs)
  • Sequential jobs that could be parallel

Use CI/CD Analytics

Location: Analytics CI/CD Analytics

Metrics:

  • Median pipeline duration
  • Success rate (failures = wasted time)
  • Most time-consuming stages

Source: GitLab CI/CD Analytics

Add Timing Instrumentation

test: script: - time npm ci # Measure dependency install - time npm run build # Measure build - time npm test # Measure tests

Output shows which step is slowest, guiding optimization efforts.

Additional Resources


Last Updated: 2026-01-08 Priority: HIGH - Implement for faster feedback loops