Report Performance Tuning Copy link
Plugin: Blitz Report Development (
tune-report-performance)Works with: Claude Code
Tune Blitz Report SQL performance by measuring buffer IO per row, identifying expensive components, and applying targeted fixes.
When to use it
- Use when a report has high MB/row or slow execution.
Claude Code
- Add the Enginatics marketplace in Claude Code.
/plugin marketplace add enginatics/ai-skills - Install the plugin.
/plugin install blitz-report-ai-skills@enginatics - Run the environment setup once; it describes your EBS development instance (never production).
/blitz-report-ai-skills:blitz-environment-setup - Run the skill, or just describe the task and Claude picks it up.
/blitz-report-ai-skills:tune-report-performance - Keep it current: enable auto-update for the enginatics marketplace, or update manually.
/plugin marketplace update enginatics
Requirements: Blitz Report installed on your Oracle EBS development instance, and database access from Claude Code to that instance (Oracle SQLcl MCP server, SQLcl or SQL*Plus). Never point the skills at production.
Systematic approach to diagnose and fix Blitz Report SQL performance issues by measuring Oracle buffer IO per row.
Performance Target
- Target: < 1 MB/row (128 buffer gets/row)
- Rule of thumb: num_tables × 4 blocks × 8KB = expected MB worst case
- Excellent: < 0.5 MB/row
- Acceptable: < 1 MB/row
- Needs work: > 1 MB/row
- Critical: > 5 MB/row
Methodology: Incremental Build-Up
Phase 1: Baseline Measurement
- Read the report XML and understand the SQL structure
- Deploy and run the report on the development instance with standard parameters
- Measure buffer_gets from v$sql:
select s.sql_id, s.buffer_gets, s.rows_processed,
round(s.buffer_gets/nullif(s.rows_processed,0)) gets_per_row,
round(s.buffer_gets/nullif(s.rows_processed,0)*8/1024,1) mb_per_row
from v$sql s
where s.sql_text like '<first_words_of_sql>%'
and s.parsing_schema_name='APPS'
and s.last_active_time>sysdate-1/24
and s.rows_processed>100
order by s.last_active_time desc;Important: v$sql.buffer_gets INCLUDES recursive SQL from PL/SQL function calls (e.g. xxen_util.meaning, hz_format_pub.format_address, xxen_util.segments_description).
Phase 2: Isolate Expensive Components
Build up the query incrementally via standalone PL/SQL test scripts. Each test adds one more component and measures the cost delta.
Test script template:
set serveroutput on size 1000000
set feedback off
declare
l_bg1 number;
l_bg2 number;
l_rows number:=0;
l_dummy varchar2(4000);
begin
fnd_global.apps_initialize(user_id=><user_id>, resp_id=>20420, resp_appl_id=>1); --your own EBS user: select fu.user_id from fnd_user fu where fu.user_name='<YOUR_USER>'
mo_global.init('M');
mo_global.set_policy_context('M', null);
select ss.value into l_bg1 from v$mystat ss, v$statname sn
where ss.statistic#=sn.statistic# and sn.name='session logical reads';
for c in (
-- TEST QUERY HERE
) loop
l_dummy:=c.col1||c.col2; -- touch all columns to force evaluation
l_rows:=l_rows+1;
end loop;
select ss.value into l_bg2 from v$mystat ss, v$statname sn
where ss.statistic#=sn.statistic# and sn.name='session logical reads';
dbms_output.put_line('Test: '||l_rows||' rows, '||(l_bg2-l_bg1)||' gets = '
||round((l_bg2-l_bg1)/nullif(l_rows,0))||' per row = '
||round((l_bg2-l_bg1)/nullif(l_rows,0)*8/1024,1)||' MB/row');
end;
/Build-up strategy:
- Test A: Core tables only (FROM/WHERE, no functions) → establishes base join cost
- Test B: Add PL/SQL function calls one at a time → identify expensive functions
- Test C: Add each CTE/subquery one at a time → identify expensive data sources
- Test D: Add each outer join one at a time → identify expensive lookups
For each test, compare gets_per_row to the previous test. A large jump (>10x) indicates the bottleneck.
Phase 3: Root Cause Analysis
Common performance killers in Blitz Reports:
CTE materialization when not needed
- Oracle materializes WITH (CTE) clauses even when outer join conditions make them unnecessary
- Fix: Add parameter guard inside CTE WHERE clause:
:show_param='Y' and - This makes Oracle skip the CTE entirely when the parameter is null
Expensive Oracle views in CTEs
ce_bank_branches_v— joins many internal tables, very expensiveap_supplier_sites_allwith DFF columns- Fix: Ensure these are gated by parameters
Correlated subqueries in SELECT
coalesce(select max()..., select max()...)patterns scan large tables per row- Fix: Make these optional via lexical parameter
&column_name
Non-cached PL/SQL functions
xxen_util.segments_description()(4-param) — does dynamic SQL per call, NOT cachedhz_format_pub.format_address()— ~19 gets per call, NOT cachedxxen_util.meaning()— uses result_cache, negligible after warmup- Fix: For non-cached functions, consider making the columns optional
Missing push_pred hints
- When outer joining to inline views/CTEs, Oracle may not push predicates
- Fix: Add
/*+ push_pred(alias) */hint
Phase 4: Apply Fixes
Parameter-gated CTE pattern:
q_expensive_data as ( select ... from expensive_view ev, ... where :show_param='Y' and -- gate: skips CTE when param is null ev.id=other.id and ... )Lexical parameter for expensive columns:
- Move expensive columns from inner subquery to outer SELECT via
&column_namelexical - Add parameter with LOV Yes, DEFAULT_VALUE=Y (so columns appear by default)
- Users can clear the parameter to exclude expensive columns
- Move expensive columns from inner subquery to outer SELECT via
Fix formatting while touching the code — no spaces around
=,<>,(+)
Phase 5: Regression Test
CRITICAL: Always do a before/after comparison:
- Run report BEFORE changes, save output Excel
- Apply fixes, deploy, run report AFTER changes
- Compare Excel files cell by cell:
- Same number of columns? (check for accidentally removed columns)
- Same data in matching columns? (check for logic bugs)
- Acceptable performance improvement?
# Quick column comparison
import openpyxl
wb1 = openpyxl.load_workbook('before.xlsx', read_only=True)
wb2 = openpyxl.load_workbook('after.xlsx', read_only=True)
h1 = [c.value for c in next(wb1.active.iter_rows(max_row=1)) if c.value]
h2 = [c.value for c in next(wb2.active.iter_rows(max_row=1)) if c.value]
print(f'Before: {len(h1)} cols, After: {len(h2)} cols')
missing = set(h1) - set(h2)
if missing: print(f'Missing columns: {missing}')Phase 6: Measure Final Performance
Run the report and check v$sql to confirm the improvement meets the target.
Submitting Reports for Testing
declare
l_run_id number;
l_error varchar2(4000);
l_request_id number;
begin
fnd_global.apps_initialize(user_id=><user_id>, resp_id=>20420, resp_appl_id=>1);
mo_global.init('M');
mo_global.set_policy_context('M', null);
l_run_id:=xxen_api.create_run_id_('Report Name');
xxen_api.set_parameter_value_(p_parameter_name=>'Operating Unit', p_value=>'Vision Operations', p_run_id=>l_run_id);
xxen_api.set_parameter_value_(p_parameter_name=>'Level', p_value=>'Site Use', p_run_id=>l_run_id);
-- set ALL required/default parameters explicitly — API does not apply XML defaults
l_error:=xxen_report.report_submit_concurrent(xxen_report.report_id('Report Name'), l_run_id, l_request_id);
dbms_output.put_line(nvl(l_error, 'Request ID: '||l_request_id));
end;Important: When submitting via API, you must set ALL required parameters and any parameters with defaults that affect the SQL. The API does NOT automatically apply <DEFAULT_VALUE> from the XML definition.
Automated Performance Loop via DBA SGA Reports
When using the convert-bip-and-rdf-reports-to-blitz pipeline, performance analysis is automated using two Blitz Reports that query Oracle's gv$sql and gv$sql_plan views.
DBA SGA SQL Performance Summary
Provides per-SQL metrics for a specific module (report). Submit with Module Name parameter set to the first 34 characters of the report name.
Key columns to analyze:
| Column | What It Tells You | Action Threshold |
|---|---|---|
| Percentage | % of total module time | Focus on entries > 5% |
| Time Exec | Seconds per execution | > 60s needs optimization |
| IO Row | MB per row | > 1 MB needs optimization |
| Code | PL/SQL package name (if SQL from function) | Check for per-row functions |
| Executions | Times this SQL was executed | > 10K suggests per-row PL/SQL function |
| Rows Exec | Rows per execution | 1 row + high executions = per-row function |
| Sorts | Total sort operations | Very high = unnecessary GROUP BY or ORDER BY |
Reading the results:
- Main report SQL — highest Percentage, has the report SQL text, shows overall Time Exec and IO Row
- PL/SQL function SQLs — have
Codecolumn populated with function name, high Executions count - Framework SQL — from XXEN_REPORT package, usually low Percentage, ignore unless > 5%
DBA SGA SQL Execution Plan History
Shows the full execution plan tree for a specific SQL_ID. Submit with SQL Id parameter.
Key patterns to look for in the plan:
| Pattern | Indicates | Fix |
|---|---|---|
Multiple VIEW PUSHED PREDICATE (VW_SSQ_N) with same base tables | Duplicate correlated subqueries | Consolidate into single inline view with CASE WHEN |
TABLE ACCESS FULL on large table | Missing index or wrong join | Check join conditions, add index hint |
HASH JOIN where NESTED LOOPS expected | Optimizer chose wrong plan | Check statistics, consider hints |
Very high Cardinality at SORT GROUP BY | Unnecessary aggregation | Remove GROUP BY, use template aggregation |
Chain of NESTED LOOPS OUTER (7+ levels) | Many correlated subqueries | Consolidate subqueries that share same tables |
Consolidation Patterns (from real-world optimization)
Pattern: Multiple correlated subqueries → single inline view
Real example: 6 tax subqueries against jai_tax_det_factors + jai_tax_lines + oe_order_lines_all, each filtering on a different tax_rate_code (SGST, CGST, IGST, FREIGHT, etc.). Each created a separate VIEW PUSHED PREDICATE in the plan.
Fix: Single inline view with conditional aggregation:
(select
oola.line_id,
sum(jtl.rounded_tax_amt_fun_curr) total_tax,
sum(case when upper(jtl.tax_rate_code) like '%SGST%' then jtl.rounded_tax_amt_fun_curr end) sgst,
sum(case when upper(jtl.tax_rate_code) like '%CGST%' then jtl.rounded_tax_amt_fun_curr end) cgst,
sum(case when upper(jtl.tax_rate_code) like '%IGST%' then jtl.rounded_tax_amt_fun_curr end) igst
from oe_order_lines_all oola, jai_tax_det_factors jtdf, jai_tax_lines jtl
where oola.header_id=jtdf.ref_doc_trx_id and oola.line_id=jtdf.ref_doc_line_id
and jtdf.det_factor_id=jtl.det_factor_id and jtdf.application_id=707
group by oola.line_id) taxJoin: d.line_id=tax.line_id(+) — Oracle pushes the predicate into the view.
Result: 6 table lookup chains per row → 1. ~60% buffer IO reduction.
Pattern: PL/SQL function called per-row → inline SQL
Real example: XX_GET_HSN_CODE(item_id, date) called 587K times (2 internal SQLs each = 1.17M context switches, 55s total).
Fix: Query all_source to find the function's SQL, then inline:
(select xhc.item_tarrif_code
from mtl_system_items_b msi, xx_hsn_code xhc
where msi.inventory_item_id=d.inventory_item_id and msi.organization_id=102
and xhc.inv_cd_seg1=msi.segment1 and xhc.seg2=msi.segment2 and xhc.inv_cd_seg6=msi.segment6
and t.trx_date between nvl(xhc.start_date,t.trx_date) and nvl(xhc.end_date,t.trx_date)
and rownum=1) hsn_codePerformance Loop Thresholds
| Metric | Pass | Fail |
|---|---|---|
| Time per execution | < 60s | >= 60s |
| IO per row | < 1 MB | >= 1 MB |
| PL/SQL function executions | < 10K | >= 10K (inline candidate) |
Max iterations: 3 optimization rounds. If still above thresholds after 3 rounds, report findings for manual review.
Parsing DBA SGA Report Output
The DBA SGA reports output XLSM files. Parse with Python/openpyxl:
import openpyxl
wb = openpyxl.load_workbook('DBA_SGA_Performance.xlsx', data_only=True)
ws = wb.active
headers = [cell.value for cell in ws[1]]
for row_idx in range(2, ws.max_row+1):
row = {headers[i]: ws.cell(row=row_idx, column=i+1).value for i in range(len(headers)) if headers[i]}
if row.get('Percentage', 0) and row['Percentage'] > 5:
print(f"SQL ID: {row.get('Sql Id')}")
print(f" Time/exec: {row.get('Time Exec', 0):.1f}s")
print(f" IO/row: {row.get('IO Row', 0):.2f} MB")
print(f" Rows/exec: {row.get('Rows Exec', 0):.0f}")
if row.get('Code'):
print(f" PL/SQL: {row['Code']} (executions: {row.get('Executions', 0)})")Key Oracle Performance Facts
- 1 buffer get = 1 Oracle 8KB block read from buffer cache
- 128 buffer gets = 1 MB of data
- v$sql.buffer_gets includes recursive SQL from PL/SQL function calls
result_cachepragma makes functions essentially free after first call- CTEs (WITH clauses) may be materialized even when not needed — always gate with parameter
push_predhint helps when outer joining to inline views but NOT when join key is always null